MVC高级特性|REST|Security认证|优雅编码
个人博客:http://www.zhenganwen.top 文末有惊喜!
环境准备
本文中所有实例代码已托管码云:https://gitee.com/zhenganwen/code-demo/tree/master/spring-security-demo
文末有惊喜!
开发环境
JDK1.8
Maven
项目结构
spring-security-demo
父工程,用于整个项目的依赖
security-core
安全认证核心模块,
security-browser
和security-app
都基于其来构建security-browser
PC端浏览器授权,主要通过
Session
security-app
移动端授权
security-demo
应用
security-browser
和security-app
依赖
spring-security-demo
添加spring
依赖自动兼容依赖和编译插件
<packaging>pom</packaging> <dependencyManagement> <dependencies> <dependency> <groupId>io.spring.platform</groupId> <artifactId>platform-bom</artifactId> <version>Brussels-SR4</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Dalston.SR2</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>2.3.2</version> <configuration> <source>1.8</source> <target>1.8</target> <encoding>UTF-8</encoding> </configuration> </plugin> </plugins> </build>
security-core
添加持久化、OAuth
认证、social
认证以及commons
工具类等依赖,一些依赖只是先加进来以备后用
<dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>org.springframework.social</groupId> <artifactId>spring-social-config</artifactId> </dependency> <dependency> <groupId>org.springframework.social</groupId> <artifactId>spring-social-core</artifactId> </dependency> <dependency> <groupId>org.springframework.social</groupId> <artifactId>spring-social-security</artifactId> </dependency> <dependency> <groupId>org.springframework.social</groupId> <artifactId>spring-social-web</artifactId> </dependency> <dependency> <groupId>commons-lang</groupId> <artifactId>commons-lang</artifactId> </dependency> <dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> </dependency> <dependency> <groupId>commons-beanutils</groupId> <artifactId>commons-beanutils</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.16.22</version> <scope>compile</scope> </dependency> </dependencies>
security-browser
添加security-core
和集群管理依赖
<dependencies> <dependency> <groupId>top.zhenganwen</groupId> <artifactId>security-core</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session</artifactId> </dependency> </dependencies>
security-app
添加security-core
<dependencies> <dependency> <groupId>top.zhenganwen</groupId> <artifactId>security-core</artifactId> <version>1.0-SNAPSHOT</version> </dependency> </dependencies>
security-demo
暂时引用security-browser
做PC端的验证
<artifactId>security-demo</artifactId> <dependencies> <dependency> <groupId>top.zhenganwen</groupId> <artifactId>security-browser</artifactId> <version>1.0-SNAPSHOT</version> </dependency> </dependencies>
配置
在security-demo
中添加启动类如下
package top.zhenganwen.securitydemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * @author zhenganwen * @date 2019/8/18 * @desc SecurityDemoApplication */ @SpringBootApplication @RestController public class SecurityDemoApplication { public static void main(String[] args) { SpringApplication.run(SecurityDemoApplication.class, args); } @RequestMapping("/hello") public String hello() { return "hello spring security"; } }
根据报错信息添加mysql
连接信息
spring.datasource.driver-class-name=com.mysql.jdbc.Driver spring.datasource.url=jdbc:mysql://127.0.0.1:3306/test?useUnicode=yes&characterEncoding=UTF-8&useSSL=false spring.datasource.username=root spring.datasource.password=123456
暂时用不到session
集群共享和redis
,先禁用掉
spring.session.store-type=none
@SpringBootApplication(exclude = {RedisAutoConfiguration.class,RedisRepositoriesAutoConfiguration.class}) @RestController public class SecurityDemoApplication {
然后发现能够启动成功了,然而访问/hello
去发现提示我们要登录,这是Spring Security
的默认认证策略在起作用,我们也先禁用它
security.basic.enabled = false
重启访问/hello
,页面显示hello spring security
,环境搭建成功
Restful
Restful VS 传统
Restful
是一种HTTP接口编写风格,而不是一种标准或规定。使用Restful
风格和传统方式的区别主要如下
- URL
- 传统方式一般通过在
URL
中添加表明接口行为的字符串和查询参数,如/user/get?username=xxx
Restful
风格则推荐一个URL代表一个系统资源,/user/1
应表示访问系统中id
为1的用户
- 传统方式一般通过在
- 请求方式
- 传统方式一般通过
get
提交,弊端是get
提交会将请求参数附在URL上,而URL有长度限制,并且若不特殊处理,参数在URL上是明文显示的,不安全。对上述两点有要求的请求会使用post
提交 Restful
风格推崇使用提交方式描述请求行为,如POST
、DELETE
、PUT
、GET
应对应增、删、改、查类型的请求
- 传统方式一般通过
- 通讯媒介
- 传统方式中,对请求的响应结果是一个页面,如此针对不同的终端需要开发多个系统,且前后端逻辑耦合
Restful
风格提倡使用JSON
作为前后端通讯媒介,前后端分离;通过响应状态码来标识响应结果类型,如200
表示请求被成功处理,404
表示没有找到相应资源,500
表示服务端处理异常。
Restful
详解参考: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-demo
的pom
中添加一个打包插件
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>1.3.3.RELEASE</version> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> <!-- 生成的jar文件名 --> <finalName>demo</finalName> </build>
这样再执行clean package
就会发现target
下生产了一个demo.jar
和demo.jar.original
,其中demo.jar
是可执行的,而demo.jar.original
是保留了maven
默认打包方式
使用MockMVC编写接口测试用例
秉着测试先行的原则(提倡先写测试用例再写接口,验证程序按照我们的想法运行),我们需要借助spring-boot-starter-test
测试框架和其中相关的MockMvc
API。mock
为打桩的意思,意为使用测试用例将程序打造牢固。
首先在security-demo
中添加测试依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency>
然后在src/test/java
中新建测试类如下
package top.zhenganwen.securitydemo; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.c.status; /** * @author zhenganwen * @date 2019/8/18 * @desc SecurityDemoApplicationTest */ @RunWith(SpringRunner.class) @SpringBootTest public class SecurityDemoApplicationTest { @Autowired WebApplicationContext webApplicationContext; private MockMvc mockMvc; @Before public void before() { mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); } @Test public void hello() throws Exception { mockMvc.perform(get("/hello").contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()) .andExpect(jsonPath("$").value("hello spring security")); } }
因为是测试HTTP接口,因此需要注入web容器WebApplicationContext
。其中get()
、status()
、jsonPath()
都是静态导入的方法,测试代码的意思是通过GET
提交方式请求/hello
(get("/hello")
)并附加请求头为Content-Type: application/json
(这样参数就会以json
的方式附在请求体中,是的没错,GET
请求也是可以附带请求体的!)
andExpect(status().isOk())
期望响应状态码为200
(参见HTTP状态码),`andExpect((jsonPath("表示
JSON`本体在Java中对应的数据类型对象,更多API详见:https://github.com/search?q=jsonpath)
其中比较重要的API为MockMvc
、MockMvcRequestBuilders
、MockMvcRequestBuilders
MockMvc
,调用perform
指定接口地址MockMvcRequestBuilders
,构建请求(包括请求路径、提交方式、请求头、请求体等)MockMvcRequestBuilders
,断言响应结果,如响应状态码、响应体
MVC注解细节
@RestController
用于标识一个Controller
为Restful Controller
,其中方法的返回结果会被SpringMVC
自动转换为JSON
并设置响应头为Content-Type=application/json
@RequestMapping
用于将URL映射到方法上,并且SpringMVC
会自动将请求参数按照按照参数名对应关系绑定到方法入参上
package top.zhenganwen.securitydemo.dto; import lombok.Data; import java.io.Serializable; /** * @author zhenganwen * @date 2019/8/18 * @desc User */ @Data @AllArgsConstructor @NoArgsConstructor public class User implements Serializable { private String username; private String password; }
package top.zhenganwen.securitydemo.web.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import top.zhenganwen.securitydemo.dto.User; import java.util.Arrays; import java.util.List; /** * @author zhenganwen * @date 2019/8/18 * @desc UserController */ @RestController public class UserController { @GetMapping("/user") public List<User> query(String username) { System.out.println(username); List<User> users = Arrays.asList(new User(), new User(), new User()); return users; } }
package top.zhenganwen.securitydemo.web.controller; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * @author zhenganwen * @date 2019/8/18 * @desc UserControllerTest */ @RunWith(SpringRunner.class) @SpringBootTest public class UserControllerTest { @Autowired private WebApplicationContext webApplicationContext; private MockMvc mockMvc; @Before public void setUp() throws Exception { mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); } @Test public void query() throws Exception { mockMvc.perform(get("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .param("username", "tom")) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(3)); } }
通过MockMvcRequestBuilders.param
可以为请求附带URL形式参数。
指定提交方式
如果没有通过method
属性指定提交方式,那么所有的提交方式都会被受理,但如果设置@RequestMapping(method = RequestMethod.GET)
,那么只有GET
请求会被受理,其他提交方式都会导致405 unsupported request method
@RequestParam
必填参数
上例代码,如果请求不附带参数username
,那么Controller
的参数就会被赋予数据类型默认值。如果你想请求必须携带该参数,否则不予处理,那么就可以使用@RequestParam
并指定required=true
(不指定也可以,默认就是)
Controller
@GetMapping("/user") public List<User> query(@RequestParam String username) { System.out.println(username); List<User> users = Arrays.asList(new User(), new User(), new User()); return users; }
ControllerTest
@Test public void testBadRequest() throws Exception { mockMvc.perform(get("/user"). contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().is4xxClientError()); }
因为请求没有附带参数username
,所以会报错400 bad request
,我们可以使用is4xxClientError()
对响应状态码为400
的请求进行断言
参数名映射
SpringMVC
默认是按参数名相同这一规则映射参数值得,如果你想将请求中参数username
的值绑定到方法参数userName
上,可以通过name
属性或value
属性
@GetMapping("/user") public List<User> query(@RequestParam(name = "username") String userName) { System.out.println(userName); List<User> users = Arrays.asList(new User(), new User(), new User()); return users; } @GetMapping("/user") public List<User> query(@RequestParam("username") String userName) { System.out.println(userName); List<User> users = Arrays.asList(new User(), new User(), new User()); return users; }
@Test public void testParamBind() throws Exception { mockMvc.perform(get("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .param("username", "tom")) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(3)); }
默认参数值
如果希望不强制请求携带某参数,但又希望方法参数在没有接收到参数值时能有个默认值(例如“”
比null
更不容易报错),那么可以通过defaultValue
属性
@GetMapping("/user") public List<User> query(@RequestParam(required = false,defaultValue = "") String userName) { Objects.requireNonNull(userName); List<User> users = Arrays.asList(new User(), new User(), new User()); return users; }
@Test public void testDefaultValue() throws Exception { mockMvc.perform(get("/user"). contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(3)); }
Bean绑定
如果请求附带的参数较多,并且各参数都隶属于某个对象的属性,那么将它们一一写在方法参列比较冗余,我们可以将它们统一封装到一个数据传输对象(Data Transportation Object DTO
)中,如
package top.zhenganwen.securitydemo.dto; import lombok.Data; /** * @author zhenganwen * @date 2019/8/19 * @desc UserCondition */ @Data public class UserQueryConditionDto { private String username; private String password; private String phone; }
然后在方法入参填写该对象即可,SpringMVC
会帮我们实现请求参数到对象属性的绑定(默认绑定规则是参数名一致)
@GetMapping("/user") public List<User> query(@RequestParam("username") String userName, UserQueryConditionDto userQueryConditionDto) { System.out.println(userName); System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto, ToStringStyle.MULTI_LINE_STYLE)); List<User> users = Arrays.asList(new User(), new User(), new User()); return users; }
ReflectionToStringBuilder
反射工具类能够在对象没有重写toString
方法时通过反射帮我们查看对象的属性。
@Test public void testDtoBind() throws Exception { mockMvc.perform(get("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .param("username", "tom") .param("password", "123456") .param("phone", "12345678911")) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(3)); }
Bean绑定不影响@RequestParam绑定
并且不用担心会和@RequestParam
冲突,输出如下
tom top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[ username=tom password=123456 phone=12345678911 ]
Bean绑定优先于基本类型参数绑定
但是,如果不给userName
添加@RequestParam
注解,那么它接收到的将是一个null
null top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[ username=tom password=123456 phone=12345678911 ]
分页参数绑定
spring-data
家族(如spring-boot-data-redis
)帮我们封装了一个分页DTOPageable
,会将我们传递的分页参数size
(每页行数)、page
(当前页码)、sort
(排序字段和排序策略)自动绑定到自动注入的Pageable
实例中
@GetMapping("/user") public List<User> query(String userName, UserQueryConditionDto userQueryConditionDto, Pageable pageable) { System.out.println(userName); System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto, ToStringStyle.MULTI_LINE_STYLE)); System.out.println(pageable.getPageNumber()); System.out.println(pageable.getPageSize()); System.out.println(pageable.getSort()); List<User> users = Arrays.asList(new User(), new User(), new User()); return users; }
@Test public void testPageable() throws Exception { mockMvc.perform(get("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .param("username", "tom") .param("password", "123456") .param("phone", "12345678911") .param("page", "2") .param("size", "30") .param("sort", "age,desc")) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(3)); }
null top.zhenganwen.securitydemo.dto.UserQueryConditionDto@24e5389c[ username=tom password=123456 phone=12345678911 ] 2 30 age: DESC
@PathVariable
变量占位
最常见的Restful URL
,像GET /user/1
获取id
为1
的用户的信息,这时我们在编写接口时需要将路径中的1
替换成一个占位符如{id}
,根据实际的URL请求动态的绑定到方法参数id
上
@GetMapping("/user/{id}") public User info(@PathVariable("id") Long id) { System.out.println(id); return new User("jack","123"); }
@Test public void testPathVariable() throws Exception { mockMvc.perform(get("/user/1"). contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()) .andExpect(jsonPath("$.username").value("jack")); } 1
当方法参数名和URL占位符变量名一致时,可以省去@PathVariable
的value
属性
正则匹配
有时我们需要对URL的匹配做细粒度的控制,例如/user/1
会匹配到/user/{id}
,而/user/xxx
则不会匹配到/user/{id}
@GetMapping("/user/{id:\\d+}") public User getInfo(@PathVariable("id") Long id) { System.out.println(id); return new User("jack","123"); }
@Test public void testRegExSuccess() throws Exception { mockMvc.perform(get("/user/1"). contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); } @Test public void testRegExFail() throws Exception { mockMvc.perform(get("/user/abc"). contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().is4xxClientError()); }
@JsonView
应用场景
有时我们需要对响应对象的某些字段进行过滤,例如查询所有用户时不显示password
字段,根据id
查询用户时则显示password
字段,这时可以通过@JsonView
注解实现此类功能
使用方法
1、声明视图接口,每个接口代表响应数据时对象字段可见策略
这里视图指的就是一种字段包含策略,后面添加@JsonView
时会用到
@Data @AllArgsConstructor @NoArgsConstructor public class User implements Serializable { /** * 普通视图,返回用户基本信息 */ public interface UserOrdinaryView { } /** * 详情视图,除了普通视图包含的字段,还返回密码等详细信息 */ public interface UserDetailsView extends UserOrdinaryView{ } private String username; private String password; }
视图和视图之间可以存在继承关系,继承视图后会继承该视图包含的字段
2、在响应对象的字段上添加视图,表示该字段包含在该视图中
@Data @AllArgsConstructor @NoArgsConstructor public class User implements Serializable { /** * 普通视图,返回用户基本信息 */ public interface UserOrdinaryView { } /** * 详情视图,除了普通视图包含的字段,还返回密码等详细信息 */ public interface UserDetailsView extends UserOrdinaryView{ } @JsonView(UserOrdinaryView.class) private String username; @JsonView(UserDetailsView.class) private String password; }
3、在Controller方法上添加视图,表示该方法返回的对象数据仅显示该视图包含的字段
@GetMapping("/user") @JsonView(User.UserBasicView.class) public List<User> query(String userName, UserQueryConditionDto userQueryConditionDto, Pageable pageable) { System.out.println(userName); System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto, ToStringStyle.MULTI_LINE_STYLE)); System.out.println(pageable.getPageNumber()); System.out.println(pageable.getPageSize()); System.out.println(pageable.getSort()); List<User> users = Arrays.asList(new User("tom","123"), new User("jack","456"), new User("alice","789")); return users; } @GetMapping("/user/{id:\\d+}") @JsonView(User.UserDetailsView.class) public User getInfo(@PathVariable("id") Long id) { System.out.println(id); return new User("jack","123"); }
测试
@Test public void testUserBasicViewSuccess() throws Exception { MvcResult mvcResult = mockMvc.perform(get("/user"). contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()) .andReturn(); System.out.println(mvcResult.getResponse().getContentAsString()); } [{"username":"tom"},{"username":"jack"},{"username":"alice"}] @Test public void testUserDetailsViewSuccess() throws Exception { MvcResult mvcResult = mockMvc.perform(get("/user/1"). contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()) .andReturn(); System.out.println(mvcResult.getResponse().getContentAsString()); } {"username":"jack","password":"123"}
阶段性重构
重构需要 小步快跑,即每写完一部分功能都要回头来看一下有哪些需要优化的地方
代码中两个方法都的RequestMapping
都用了/user
,我们可以将其提至类上以供复用
@RestController @RequestMapping("/user") public class UserController { @GetMapping @JsonView(User.UserBasicView.class) public List<User> query(String userName, UserQueryConditionDto userQueryConditionDto, Pageable pageable) { System.out.println(userName); System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto, ToStringStyle.MULTI_LINE_STYLE)); System.out.println(pageable.getPageNumber()); System.out.println(pageable.getPageSize()); System.out.println(pageable.getSort()); List<User> users = Arrays.asList(new User("tom","123"), new User("jack","456"), new User("alice","789")); return users; } @GetMapping("/{id:\\d+}") @JsonView(User.UserDetailsView.class) public User getInfo(@PathVariable("id") Long id) { System.out.println(id); return new User("jack","123"); } }
虽然是一个很细节的问题,但是一定要有这个思想和习惯
别忘了重构后重新运行一遍所有的测试用例,确保重构没有更改程序行为
处理请求体
@RequestBody映射请求体到Java方法的参数
SpringMVC
默认不会解析请求体中的参数并绑定到方法参数
@PostMapping public void createUser(User user) { System.out.println(user); }
@Test public void testCreateUser() throws Exception { mockMvc.perform(post("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"jack\",\"password\":\"123\"}")) .andExpect(status().isOk()); } User(id=null, username=null, password=null)
使用@RequestBody
可以将请求体中的JSON
数据解析成Java对象并绑定到方法入参
@PostMapping public void createUser(@RequestBody User user) { System.out.println(user); }
@Test public void testCreateUser() throws Exception { mockMvc.perform(post("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"jack\",\"password\":\"123\"}")) .andExpect(status().isOk()); } User(id=null, username=jack, password=123)
日期类型参数处理
如果需要将时间类型数据绑定到Bean
的Date
字段上,网上常见的解决方案是加一个json
消息转换器进行格式化,这样的话就将日期的显示逻辑写死在后端的。
比较好的做法应该是后端只保存时间戳,传给前端时也只传时间戳,将格式化显示的责任交给前端,前端爱怎么显示怎么显示
@PostMapping public void createUser(@RequestBody User user) { System.out.println(user); }
@Test public void testDateBind() throws Exception { Date date = new Date(); System.out.println(date.getTime()); mockMvc.perform(post("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}")) .andExpect(status().isOk()); } 1566212381139 User(id=null, username=jack, password=123, birthday=Mon Aug 19 18:59:41 CST 2019)
@Valid注解验证请求参数的合法性
抽离校验逻辑
在Controller
方法中,我们经常需要对请求参数进行合法性校验后再执行处理逻辑,传统的写法是使用if
判断
@PostMapping public void createUser(@RequestBody User user) { if (StringUtils.isBlank(user.getUsername())) { throw new IllegalArgumentException("用户名不能为空"); } if (StringUtils.isBlank(user.getPassword())) { throw new IllegalArgumentException("密码不能为空"); } System.out.println(user); }
但是如果其他地方也需要校验就需要编写重复的代码,一旦校验逻辑发生改变就需要改变多处,并且如果有所遗漏还会给程序埋下隐患。有点重构意识的可能会将每个校验逻辑单独封装一个方法,但仍显冗余。
SpringMVC Restful
则推荐使用@Valid
来实现参数的校验,并且未通过校验的会响应400 bad request
给前端,以状态码表示处理结果(及请求格式不对),而不是像上述代码一样直接抛异常导致前端收到的状态码是500
首先我们要使用hibernate-validator
校验框架提供的一些约束注解来约束Bean
字段
@NotBlank @JsonView(UserBasicView.class) private String username; @NotBlank @JsonView(UserDetailsView.class) private String password;
仅添加这些注解,SpringMVC
是不会帮我们校验的
@PostMapping public void createUser(@RequestBody User user) { System.out.println(user); }
@Test public void testConstraintValidateFail() throws Exception { mockMvc.perform(post("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"\"}")) .andExpect(status().isOk()); } User(id=null, username=, password=null, birthday=null)
我们还要在需要校验的Bean
前添加@Valid
注解,这样SpringMVC
会根据我们在该Bean
中添加的约束注解进行校验,在校验不通过时响应400 bad request
@PostMapping public void createUser(@Valid @RequestBody User user) { System.out.println(user); }
@Test public void testConstraintValidateSuccess() throws Exception { mockMvc.perform(post("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"\"}")) .andExpect(status().is4xxClientError()); }
约束注解
hibernate-validator
提供的约束注解如下
例如,创建用户时限制请求参数中的birthday
的值是一个过去时间
首先在Bean
的字段添加约束注解
@Past private Date birthday;
然后在要验证的Bean
前添加@Valid
注解
@PostMapping public void createUser(@Valid @RequestBody User user) { System.out.println(user); }
@Test public void testValidatePastTimeSuccess() throws Exception { // 获取一年前的时间点 Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); mockMvc.perform(post("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}")) .andExpect(status().isOk()); } @Test public void testValidatePastTimeFail() throws Exception { // 获取一年后的时间点 Date date = new Date(LocalDateTime.now().plusYears(1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); mockMvc.perform(post("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}")) .andExpect(status().is4xxClientError()); }
复用校验逻辑
这样,如果我们需要对修改用户的方法添加校验,只需添加@Valid
即可
@PutMapping("/{id}") public void update(@Valid @RequestBody User user, @PathVariable Long id) { System.out.println(user); System.out.println(id); }
@Test public void testUpdateSuccess() throws Exception { mockMvc.perform(put("/user/1"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"jack\",\"password\":\"789\"}")) .andExpect(status().isOk()); } User(id=null, username=jack, password=789, birthday=null) 1 @Test public void testUpdateFail() throws Exception { mockMvc.perform(put("/user/1"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"jack\",\"password\":\" \"}")) .andExpect(status().is4xxClientError()); }
约束逻辑只需在Bean
中通过约束注解声明一次,其他任何需要使用到该约束校验的地方只需添加@Valid
即可
BindingResult处理校验结果
上述处理方式还是不够完美,我们只是通过响应状态码告诉前端请求数据格式不对,但是没有明确指明哪里不对,我们需要给前端一些更明确的信息
上例中,如果没有通过校验,那么方法就不会被执行而直接返回了,我们想要插入一些提示信息都没有办法编写。这时可以使用BindingResult
,它能够帮助我们获取校验失败信息并返回给前端,同时响应状态码会变为200
@PostMapping public void createUser(@Valid @RequestBody User user,BindingResult errors) { if (errors.hasErrors()) { errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage())); } System.out.println(user); } @PutMapping("/{id}") public void update(@PathVariable Long id,@Valid @RequestBody User user, BindingResult errors) { if (errors.hasErrors()) { errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage())); } System.out.println(user); System.out.println(id); }
@Test public void testBindingResult() throws Exception { Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); mockMvc.perform(post("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"jack\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}")) .andExpect(status().isOk()); } may not be empty User(id=null, username=jack, password=null, birthday=Sun Aug 19 20:44:02 CST 2018) @Test public void testBindingResult2() throws Exception { Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); mockMvc.perform(put("/user/1"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"jack\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}")) .andExpect(status().isOk()); } may not be empty User(id=null, username=jack, password=null, birthday=Sun Aug 19 20:42:56 CST 2018) 1
值得注意的是,
BindingResult
必须和@Valid
一起使用,并且在参列中的位置必须紧跟在@Valid
修饰的参数后面,否则会出现如下令人困惑的结果
@PutMapping("/{id}") public void update(@Valid @RequestBody User user, @PathVariable Long id, BindingResult errors) { if (errors.hasErrors()) { errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage())); } System.out.println(user); System.out.println(id); }
上述代码中,在校验的Bean
和BindingResult
之间插入了一个id
,你会发现BindingResult
不起作用了
@Test public void testBindingResult2() throws Exception { Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); mockMvc.perform(put("/user/1"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"jack\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}")) .andExpect(status().isOk()); } java.lang.AssertionError: Status Expected :200 Actual :400
校验
自定义消息
现在我们可以通过BindingResult
得到校验失败信息了
@PutMapping("/{id:\\d+}") public void update(@PathVariable Long id, @Valid @RequestBody User user, BindingResult errors) { if (errors.hasErrors()) { errors.getAllErrors().stream().forEach(error -> { FieldError fieldError = (FieldError) error; System.out.println(fieldError.getField() + " " + fieldError.getDefaultMessage()); }); } System.out.println(user); }
@Test public void testBindingResult3() throws Exception { Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); mockMvc.perform(put("/user/1"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\" \",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}")) .andExpect(status().isOk()); } password may not be empty username may not be empty User(id=null, username= , password=null, birthday=Sun Aug 19 20:56:35 CST 2018)
但是默认的消息提示不太友好并且还需要我们自己拼接,这时我们需要自定义消息提示,只需要使用约束注解的message
属性指定验证未通过的提示消息即可
@NotBlank(message = "用户名不能为空") @JsonView(UserBasicView.class) private String username; @NotBlank(message = "密码不能为空") @JsonView(UserDetailsView.class) private String password;
@Test public void testBindingResult3() throws Exception { Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); mockMvc.perform(put("/user/1"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\" \",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}")) .andExpect(status().isOk()); } password 密码不能为空 username 用户名不能为空 User(id=null, username= , password=null, birthday=Sun Aug 19 21:03:18 CST 2018)
自定义校验注解
虽然hibernate-validator
提供了一些常用的约束注解,但是对于复杂的业务场景还是需要我们自定义一个约束注解,毕竟有时仅仅是非空或格式合法的校验是不够的,可能我们需要去数据库查询进行校验
下面我们就参考已有的约束注解照葫芦画瓢自定义一个“用户名不可重复”的约束注解
1、新建约束注解类
我们希望该注解标注在Bean
的某些字段上,使用@Target({FIELD})
;此外,要想该注解在运行期起作用,还要添加@Retention(RUNTIME)
package top.zhenganwen.securitydemo.annotation.valid; import javax.validation.Constraint; import javax.validation.Payload; import java.lang.annotation.Retention; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.RetentionPolicy.RUNTIME; /** * @author zhenganwen * @date 2019/8/20 * @desc Unrepeatable */ @Target({FIELD}) @Retention(RUNTIME) public @interface Unrepeatable { }
参考已有的约束注解如NotNull
、NotBlank
,它们都有三个方法
String message() default "{org.hibernate.validator.constraints.NotBlank.message}"; Class<?>[] groups() default { }; Class<? extends Payload>[] payload() default { };
于是我们也声明这三个方法
@Target({FIELD}) @Retention(RUNTIME) public @interface Unrepeatable { String message() default "用户名已被注册"; Class<?>[] groups() default { }; Class<? extends Payload>[] payload() default { }; }
2、编写校验逻辑类
依照已有注解,它们都还有一个注解@Constraint
@Documented @Constraint(validatedBy = { }) @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) @Retention(RUNTIME) @ReportAsSingleViolation @NotNull public @interface NotBlank {
按住Ctrl
点击validateBy
属性进行查看,发现它需要一个ConstraintValidator
的实现类,现在我们需要编写一个ConstraintValidator
自定义校验逻辑并通过validatedBy
属性将其绑定到我们的Unrepeatable
注解上
package top.zhenganwen.securitydemo.annotation.valid; import org.springframework.beans.factory.annotation.Autowired; import top.zhenganwen.securitydemo.service.UserService; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; /** * @author zhenganwen * @date 2019/8/20 * @desc UsernameUnrepeatableValidator */ public class UsernameUnrepeatableValidator implements ConstraintValidator<Unrepeatable,String> { @Autowired private UserService userService; @Override public void initialize(Unrepeatable unrepeatableAnnotation) { System.out.println(unrepeatableAnnotation); System.out.println("UsernameUnrepeatableValidator initialized==================="); } @Override public boolean isValid(String value, ConstraintValidatorContext context) { System.out.println("the request username is " + value); boolean ifExists = userService.checkUsernameIfExists( value); // 如果用户名存在,则拒绝请求并提示用户名已被注册,否则处理请求 return ifExists == true ? false : true; } }
其中,ConstraintValidator<A,T>
泛型A
指定为要绑定到的注解,T
指定要校验字段的类型;isValid
用来编写自定义校验逻辑,如查询数据库是否存在该用户名的记录,返回true
表示校验通过,false
校验失败
@ComponentScan
扫描范围内的ConstraintValidator
实现类会被Spring
注入到容器中,因此你无须在该类上标注Component
即可在类中注入其他Bean
,例如本例中注入了一个UserService
package top.zhenganwen.securitydemo.service; import org.springframework.stereotype.Service; import java.util.Objects; /** * @author zhenganwen * @date 2019/8/20 * @desc UserService */ @Service public class UserService { public boolean checkUsernameIfExists(String username) { // select count(username) from user where username=? // as if username "tom" has been registered if (Objects.equals(username, "tom")) { return true; } return false; } }
3、在约束注解上指定校验类
通过validatedBy
属性指定该注解绑定的一系列校验类(这些校验类必须是ConstraintValidator<A,T>
的实现类
@Target({FIELD}) @Retention(RUNTIME) @Constraint(validatedBy = { UsernameUnrepeatableValidator.class}) public @interface Unrepeatable { String message() default "用户名已被注册"; Class<?>[] groups() default { }; Class<? extends Payload>[] payload() default { }; }
4、测试
@PostMapping public void createUser(@Valid @RequestBody User user,BindingResult errors) { if (errors.hasErrors()) { errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage())); } System.out.println(user); }
@Test public void testCreateUserWithNewUsername() throws Exception { Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); mockMvc.perform(post("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"alice\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}")) .andExpect(status().isOk()); } the request username is alice User(id=null, username=alice, password=123, birthday=Mon Aug 20 08:25:11 CST 2018) @Test public void testCreateUserWithExistedUsername() throws Exception { Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); mockMvc.perform(post("/user"). contentType(MediaType.APPLICATION_JSON_UTF8) .content("{\"username\":\"tom\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}")) .andExpect(status().isOk()); } the request username is tom 用户名已被注册 User(id=null, username=tom, password=123, birthday=Mon Aug 20 08:25:11 CST 2018)
删除用户
@Test public void testDeleteUser() throws Exception { mockMvc.perform(delete("/user/1"). contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); } java.lang.AssertionError: Status Expected :200 Actual :405
测试先行,即先写测试用例后写功能代码,即使我们知道没有编写该功能测试肯定不会通过,但测试代码也是需要检验的,确保测试逻辑的正确性
Restful
提倡以响应状态码来表示请求处理结果,例如200表示删除成功,若没有特别要求需要返回某些信息,那么无需添加响应体
@DeleteMapping("/{id:\\d+}") public void delete(@PathVariable Long id) { System.out.println(id); // delete user }
@Test public void testDeleteUser() throws Exception { mockMvc.perform(delete("/user/1"). contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); } 1
错误处理
SpringBoot默认的错误处理机制
区分客户端进行响应
当请求处理发生错误时,SpringMVC
根据客户端的类型会有不同的响应结果,例如浏览器访问localhost:8080/xxx
会返回如下错误页面
而使用Postman
请求则会得到如下响应
{ "timestamp": 1566268880358, "status": 404, "error": "Not Found", "message": "No message available", "path": "/xxx" }
该机制对应的源码在BasicErrorController
中(发生4xx
或500
异常时,会将请求转发到/error
,由BasicErrorController
决定异常响应逻辑)
@RequestMapping(produces = "text/html") public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) { HttpStatus status = getStatus(request); Map<String, Object> model = Collections.unmodifiableMap(getErrorAttributes( request, isIncludeStackTrace(request, MediaType.TEXT_HTML))); response.setStatus(status.value()); ModelAndView modelAndView = resolveErrorView(request, response, status, model); return (modelAndView == null ? new ModelAndView("error", model) : modelAndView); } @RequestMapping @ResponseBody public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) { Map<String, Object> body = getErrorAttributes(request, 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.html
或500.html
创建src/main/resources/resources/error
文件夹并添加404.html
和500.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>页面找不到了</title> </head> <body> 抱歉,页面找不到了! </body> </html>
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>服务异常</title> </head> <body> 服务端内部错误 </body> </html>
模拟处理请求时发生异常
@GetMapping("/{id:\\d+}") @JsonView(User.UserDetailsView.class) public User getInfo(@PathVariable("id") Long id) { throw new RuntimeException("id不存在"); // System.out.println(id); // return new User(1L, "jack", "123"); // return null; }
访问localhost:8080/xxx
显示404.html
页面,访问localhost:8080/user/1
显示500.html
页面
值得注意的是,自定义异常页面并不会导致非浏览器请求也会响应该页面
自定义异常处理
对于4XX
的客户端错误,SpringMVC
会直接返回错误响应和不会执行Controller
方法;对于500
的服务端抛出异常,则会收集异常类的message
字段值返回
默认异常响应结果
例如客户端错误,GET /user/1
{ "timestamp": 1566270327128, "status": 500, "error": "Internal Server Error", "exception": "java.lang.RuntimeException", "message": "id不存在", "path": "/user/1" }
例如服务端错误
@PostMapping public void createUser(@Valid @RequestBody User user) { System.out.println(user); }
POST localhost:8080/user Body {}
{ "timestamp": 1566272056042, "status": 400, "error": "Bad Request", "exception": "org.springframework.web.bind.MethodArgumentNotValidException", "errors": [ { "codes": [ "NotBlank.user.username", "NotBlank.username", "NotBlank.java.lang.String", "NotBlank" ], "arguments": [ { "codes": [ "user.username", "username" ], "arguments": null, "defaultMessage": "username", "code": "username" } ], "defaultMessage": "用户名不能为空", "objectName": "user", "field": "username", "rejectedValue": null, "bindingFailure": false, "code": "NotBlank" }, { "codes": [ "NotBlank.user.password", "NotBlank.password", "NotBlank.java.lang.String", "NotBlank" ], "arguments": [ { "codes": [ "user.password", "password" ], "arguments": null, "defaultMessage": "password", "code": "password" } ], "defaultMessage": "密码不能为空", "objectName": "user", "field": "password", "rejectedValue": null, "bindingFailure": false, "code": "NotBlank" } ], "message": "Validation failed for object='user'. Error count: 2", "path": "/user" }
自定义异常响应结果
有时我们需要经常在处理请求时抛出异常以终止对该请求的处理,例如
package top.zhenganwen.securitydemo.web.exception.response; import lombok.Data; import java.io.Serializable; /** * @author zhenganwen * @date 2019/8/20 * @desc IdNotExistException */ @Data public class IdNotExistException extends RuntimeException { private Serializable id; public IdNotExistException(Serializable id) { super("id不存在"); this.id = id; } }
@GetMapping("/{id:\\d+}") @JsonView(User.UserDetailsView.class) public User getInfo(@PathVariable("id") Long id) { throw new IdNotExistException(id); }
GET /user/1
{ "timestamp": 1566270990177, "status": 500, "error": "Internal Server Error", "exception": "top.zhenganwen.securitydemo.exception.response.IdNotExistException", "message": "id不存在", "path": "/user/1" }
SpringMVC
默认只会将异常的message
返回,如果我们需要将IdNotExistException
的id
也返回以给前端更明确的提示,就需要我们自定义异常处理
- 自定义的异常处理类需要添加
@ControllerAdvice
- 在处理异常的方法上使用
@ExceptionHandler
声明该方法要截获哪些异常,所有的Controller
若抛出这些异常中的一个则会转为执行该方法 - 捕获到的异常会作为方法的入参
- 方法返回的结果与
Controller
方法返回的结果意义相同,如果需要返回json
则需在方法上添加@ResponseBody
注解,如果在类上添加该注解则表示每个方法都有该注解
package top.zhenganwen.securitydemo.web.exception.handler; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; import top.zhenganwen.securitydemo.web.exception.response.IdNotExistException; import java.util.HashMap; import java.util.Map; /** * @author zhenganwen * @date 2019/8/20 * @desc UserControllerExceptionHandler */ @ControllerAdvice @ResponseBody public class UserControllerExceptionHandler { @ExceptionHandler(IdNotExistException.class) public Map<String, Object> handleIdNotExistException(IdNotExistException e) { Map<String, Object> jsonResult = new HashMap<>(); jsonResult.put("message", e.getMessage()); jsonResult.put("id", e.getId()); return jsonResult; } }
重启后使用Postman GET /user/1
得到响应如下
{ "id": 1, "message": "id不存在" }
拦截
需求:记录所有请求 的处理时间
过滤器Filter
过滤器是JavaEE
中的标准,是不依赖SpringMVC
的,要想在SpringMVC
中使用过滤器需要两步
1、实现Filter
接口并注入到Spring容器
package top.zhenganwen.securitydemo.web.filter; import org.springframework.stereotype.Component; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Date; /** * @author zhenganwen * @date 2019/8/20 * @desc TimeFilter */ @Component public class TimeFilter implements Filter { // 在web容器启动时执行 @Override public void init(FilterConfig filterConfig) throws ServletException { System.out.println("TimeFilter init"); } // 在收到请求时执行,这时请求还未到达SpringMVC的入口DispatcherServlet // 单次请求只会执行一次(不论期间发生了几次请求转发) @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"); String service = "【" + request.getMethod() + " " + request.getRequestURI() + "】"; System.out.println("[TimeFilter] 收到服务调用:" + service); Date start = new Date(); System.out.println("[TimeFilter] 开始执行服务" + service + simpleDateFormat.format(start)); filterChain.doFilter(servletRequest, servletResponse); Date end = new Date(); System.out.println("[TimeFilter] 服务" + service + "执行完毕 " + simpleDateFormat.format(end) + ",共耗时:" + (end.getTime() - start.getTime()) + "ms"); } // 在容器销毁时执行 @Override public void destroy() { System.out.println("TimeFilter destroyed"); } }
2、配置FilterRegistrationBean
,这一步相当于传统方式在web.xml
中添加一个<Filter>
节点
package top.zhenganwen.securitydemo.web.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import top.zhenganwen.securitydemo.web.filter.TimeFilter; /** * @author zhenganwen * @date 2019/8/20 * @desc WebConfig */ @Configuration public class WebConfig { @Autowired TimeFilter timeFilter; // 添加这个bean相当于在web.xml中添加一个Fitler节点 @Bean public FilterRegistrationBean registerTimeFilter() { FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean(); filterRegistrationBean.setFilter(timeFilter); return filterRegistrationBean; } }
3、测试
访问GET /user/1
,控制台日志如下
@GetMapping("/{id:\\d+}") @JsonView(User.UserDetailsView.class) public User getInfo(@PathVariable("id") Long id) { // throw new IdNotExistException(id); User user = new User(); return user; }
[TimeFilter] 收到服务调用:【GET /user/1】 [TimeFilter] 开始执行服务【GET /user/1】2019-08-20 02:13:44 [TimeFilter] 服务【GET /user/1】执行完毕 2019-08-20 02:13:44,共耗时:4ms
由于Filter
是JavaEE
中的标准,所以它仅依赖servlet-api
而不依赖任何第三方类库,因此它自然也不知道Controller
的存在,自然也就无法知道本次请求将被映射到哪个方法上,SpringMVC
通过引入***弥补了这一缺点
通过filterRegistrationBean.addUrlPattern
可以为过滤器添加拦截规则,默认的拦截规则是所有URL
@Bean public FilterRegistrationBean registerTimeFilter() { FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean(); filterRegistrationBean.setFilter(timeFilter); filterRegistrationBean.addUrlPatterns("/*"); return filterRegistrationBean; }
***Interceptor
***与Filter
的有如下不同之处
Filter
是基于请求的,Interceptor
是基于Controller
的,一次请求可能会执行多个Controller
(通过转发),因此一次请求只会执行一次Filter
但可能执行多次Interceptor
Interceptor
是SpringMVC
中的组件,因此它知道Controller
的存在,能够获取相关信息(如该请求映射的方法,方法所在的bean
等)
使用SpringMVC
提供的***也需要两步
1、实现HandlerInterceptor
接口
package top.zhenganwen.securitydemo.web.interceptor; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.text.SimpleDateFormat; import java.util.Date; /** * @author zhenganwen * @date 2019/8/20 * @desc TimeInterceptor */ @Component public class TimeInterceptor implements HandlerInterceptor { /** * 在Controller方法执行前被执行 * @param httpServletRequest * @param httpServletResponse * @param handler 处理器(Controller方法的封装) * @return true 会接着执行Controller方法 * false 不会执行Controller方法,直接响应200 * @throws Exception */ @Override public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler) throws Exception { HandlerMethod handlerMethod = (HandlerMethod) handler; String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】"; Date start = new Date(); System.out.println("[TimeInterceptor # preHandle] 服务" + service + "被调用 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(start)); httpServletRequest.setAttribute("start", start.getTime()); return true; } /** * 在Controller方法正常执行完毕后执行,如果Controller方法抛出异常则不会执行此方法 * @param httpServletRequest * @param httpServletResponse * @param handler * @param modelAndView Controller方法返回的视图 * @throws Exception */ @Override public void postHandle(HttpServletRequest httpServletRequest, 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方法添加自定义***
@Configuration public class WebConfig extends WebMvcConfigurerAdapter { @Autowired TimeFilter timeFilter; @Autowired TimeInterceptor timeInterceptor; @Bean public FilterRegistrationBean registerTimeFilter() { FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean(); filterRegistrationBean.setFilter(timeFilter); filterRegistrationBean.addUrlPatterns("/*"); return filterRegistrationBean; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(timeInterceptor); } }
多次调用addInterceptor
可添加多个***
3、测试
GET /user/1
[TimeFilter] 收到服务调用:【GET /user/1】 [TimeFilter] 开始执行服务【GET /user/1】2019-08-20 02:59:00 [TimeInterceptor # preHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被调用 2019-08-20 02:59:00 [TimeFilter] 服务【GET /user/1】执行完毕 2019-08-20 02:59:00,共耗时:2ms
- 将
preHandle
返回值改为true
[TimeFilter] 收到服务调用:【GET /user/1】 [TimeFilter] 开始执行服务【GET /user/1】2019-08-20 02:59:20 [TimeInterceptor # preHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被调用 2019-08-20 02:59:20 [TimeInterceptor # postHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】调用结束 2019-08-20 02:59:20 共耗时:39ms [TimeInterceptor # afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】调用结束 2019-08-20 02:59:20 共耗时:39ms [TimeFilter] 服务【GET /user/1】执行完毕 2019-08-20 02:59:20,共耗时:42ms
- 在Controller方法中抛出异常
@GetMapping("/{id:\\d+}") @JsonView(User.UserDetailsView.class) public User getInfo(@PathVariable("id") Long id) { throw new IdNotExistException(id); // User user = new User(); // return user; }
[TimeFilter] 收到服务调用:【GET /user/1】 [TimeFilter] 开始执行服务【GET /user/1】2019-08-20 03:05:56 [TimeInterceptor # preHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被调用 2019-08-20 03:05:56 [TimeInterceptor # afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】调用结束 2019-08-20 03:05:56 共耗时:11ms [TimeFilter] 服务【GET /user/1】执行完毕 2019-08-20 03:05:56,共耗时:14ms
发现afterCompletion
中的异常打印逻辑并未被执行,这是因为IdNotExistException
被我们之前自定义的异常处理器处理掉了,没有抛出来。我们改为抛出RuntimeException
再试一下
@GetMapping("/{id:\\d+}") @JsonView(User.UserDetailsView.class) public User getInfo(@PathVariable("id") Long id) { throw new RuntimeException("id not exist"); }
[TimeFilter] 收到服务调用:【GET /user/1】 [TimeFilter] 开始执行服务【GET /user/1】2019-08-20 03:09:38 [TimeInterceptor # preHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被调用 2019-08-20 03:09:38 [TimeInterceptor # afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】调用结束 2019-08-20 03:09:38 共耗时:7ms [TimeInterceptor#afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】调用异常:id not exist java.lang.RuntimeException: id not exist at top.zhenganwen.securitydemo.web.controller.UserController.getInfo(UserController.java:42) ... [TimeInterceptor # preHandle] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】被调用 2019-08-20 03:09:38 [TimeInterceptor # postHandle] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】调用结束 2019-08-20 03:09:38 共耗时:7ms [TimeInterceptor # afterCompletion] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】调用结束 2019-08-20 03:09:38 共耗时:7ms
方法调用时序图大致如下
切片Aspect
应用场景
Interceptor
仍然有它的局限性,即无法获取调用Controller方法的入参信息,例如我们需要对用户下单请求的订单物品信息记录日志以便为推荐系统提供数据,那么这时Interceptor
就无能为力了
追踪源码DispatcherServlet -> doService -> doDispatch
可发现Interceptor
无法获取入参的原因:
if (!mappedHandler.applyPreHandle(processedRequest, response)) { return; } // Actually invoke the handler. mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
mappedHandler.applyPreHandle
其实就是调用HandlerInterceptor
的preHandle
方法,而在此之后才调用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
实例 - 通过调用
ProceedingJoinPoint
的point.proceed()
能够调用对应的Controller方法并拿到返回值
- 其中只有
package top.zhenganwen.securitydemo.web.aspect; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Date; /** * @author zhenganwen * @date 2019/8/20 * @desc GlobalControllerAspect */ @Aspect @Component public class GlobalControllerAspect { // top.zhenganwen.securitydemo.web.controller包下的所有Controller的所有方法 @Around("execution(* top.zhenganwen.securitydemo.web.controller.*.*(..))") public Object handleControllerMethod(ProceedingJoinPoint point) throws Throwable { // handler对应的方法签名(哪个类的哪个方法,参数列表是什么) String service = "【"+point.getSignature().toLongString()+"】"; // 传入handler的参数值 Object[] args = point.getArgs(); SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"); Date start = new Date(); System.out.println("[GlobalControllerAspect]开始调用服务" + service + " 请求参数: " + Arrays.toString(args) + ", " + simpleDateFormat.format(start)); Object result = null; try { // 调用实际的handler并取得结果 result = point.proceed(); } catch (Throwable throwable) { System.out.println("[GlobalControllerAspect]调用服务" + service + "发生异常, message=" + throwable.getMessage()); throw throwable; } Date end = new Date(); System.out.println("[GlobalControllerAspect]服务" + service + "调用结束,响应结果为: " + result+", "+simpleDateFormat.format(end)+", 共耗时: "+(end.getTime()-start.getTime())+ "ms"); // 返回响应结果,不一定要和handler的处理结果一致 return result; } }
测试
@GetMapping("/{id:\\d+}") @JsonView(User.UserDetailsView.class) public User getInfo(@PathVariable("id") Long id) { System.out.println("[UserController # getInfo]query user by id"); return new User(); }
GET /user/1
[TimeFilter] 收到服务调用:【GET /user/1】 [TimeFilter] 开始执行服务【GET /user/1】2019-08-20 05:21:48 [TimeInterceptor # preHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】被调用 2019-08-20 05:21:48 [GlobalControllerAspect]开始调用服务【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】 请求参数: [1], 2019-08-20 05:21:48 [UserController # getInfo]query user by id [GlobalControllerAspect]服务【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】调用结束,响应结果为: User(id=null, username=null, password=null, birthday=null), 2019-08-20 05:21:48, 共耗时: 0ms [TimeInterceptor # postHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】调用结束 2019-08-20 05:21:48 共耗时:4ms [TimeInterceptor # afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】调用结束 2019-08-20 05:21:48 共耗时:4ms [TimeFilter] 服务【GET /user/1】执行完毕 2019-08-20 05:21:48,共耗时:6ms
[TimeFilter] 收到服务调用:【GET /user/1】 [TimeFilter] 开始执行服务【GET /user/1】2019-08-20 05:24:40 [TimeInterceptor # preHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】被调用 2019-08-20 05:24:40 [GlobalControllerAspect]开始调用服务【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】 请求参数: [1], 2019-08-20 05:24:40 [UserController # getInfo]query user by id [GlobalControllerAspect]调用服务【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】发生异常, message=id not exist [TimeInterceptor # afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】调用结束 2019-08-20 05:24:40 共耗时:2ms [TimeInterceptor#afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】调用异常:id not exist java.lang.RuntimeException: id not exist at top.zhenganwen.securitydemo.web.controller.UserController.getInfo(UserController.java:42) ... [TimeInterceptor # preHandle] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】被调用 2019-08-20 05:24:40 [TimeInterceptor # postHandle] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】调用结束 2019-08-20 05:24:40 共耗时:2ms [TimeInterceptor # afterCompletion] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】调用结束 2019-08-20 05:24:40 共耗时:3ms
总结
请求过程
响应过程
文件上传下载及Mock测试
文件上传
老规矩,测试先行,不过使用MockMvc
模拟文件上传请求还是有些不一样的,请求需要使用静态方法fileUpload
且要设置contentType
为multipart/form-data
@Test public void upload() throws Exception { File file = new File("C:\\Users\\zhenganwen\\Desktop", "hello.txt"); FileInputStream fis = new FileInputStream(file); byte[] content = new byte[fis.available()]; fis.read(content); String fileKey = mockMvc.perform(fileUpload("/file") /** * name 请求参数,相当于<input>标签的的`name`属性 * originalName 上传的文件名称 * contentType 上传文件需指定为`multipart/form-data` * content 字节数组,上传文件的内容 */ .file(new MockMultipartFile("file", "hello.txt", "multipart/form-data", content))) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); System.out.println(fileKey); }
文件管理Controller
package top.zhenganwen.securitydemo.web.controller; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.util.Date; /** * @author zhenganwen * @date 2019/8/21 * @desc FileController */ @RestController @RequestMapping("/file") public class FileController { public static final String FILE_STORE_FOLDER = "C:\\Users\\zhenganwen\\Desktop\\"; @PostMapping public String upload(MultipartFile file) throws IOException { System.out.println("[FileController]文件请求参数: " + file.getName()); System.out.println("[FileController]文件名称: " + file.getName()); System.out.println("[FileController]文件大小: "+file.getSize()+"字节"); String fileKey = new Date().getTime() + "_" + file.getOriginalFilename(); File storeFile = new File(FILE_STORE_FOLDER, fileKey); // 可以通过file.getInputStream将文件上传到FastDFS、云OSS等存储系统中 // InputStream inputStream = file.getInputStream(); // byte[] content = new byte[inputStream.available()]; // inputStream.read(content); file.transferTo(storeFile); return fileKey; } }
测试结果
[FileController]文件请求参数: file [FileController]文件名称: file [FileController]文件大小: 12字节 1566349460611_hello.txt
查看桌面发现多了一个1566349460611_hello.txt
并且其中的内容为hello upload
文件下载
引入apache io
工具包
<dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.5</version> </dependency>
文件下载接口
@GetMapping("/{fileKey:.+}") public void download(@PathVariable String fileKey, HttpServletResponse response) throws IOException { try ( InputStream is = new FileInputStream(new File(FILE_STORE_FOLDER, fileKey)); OutputStream os = response.getOutputStream() ) { // 下载需要设置响应头为 application/x-download response.setContentType("application/x-download"); // 设置下载询问框中的文件名 response.setHeader("Content-Disposition", "attachment;filename=" + fileKey); IOUtils.copy(is, os); os.flush(); } }
测试:浏览器访问http://localhost:8080/file/1566349460611_hello.txt
映射写成/{fileKey:.+}
而不是/{fileKey}
的原因是SpringMVC
会忽略映射中.
符号之后的字符。正则.+
表示匹配任意个非\n
的字符,不加该正则的话,方法入参fileKey
获取到的值将是1566349460611_hello
而不是1566349460611_hello.txt
异步处理REST服务
我们之前都是客户端每发送一个请求,tomcat
线程池就派一个线程进行处理,直到请求处理完成响应结果,该线程都是被占用的。一旦系统并发量上来了,那么tomcat
线程池会显得分身乏力,这时我们可以采取异步处理的方式。
为避免前文添加的过滤器、***、切片日志的干扰,我们暂时先注释掉
//@Component public class TimeFilter implements Filter {
突然发现实现过滤器好像继承了
Filter
接口并添加@Component
就能生效,因为仅注释掉WebConfig
中的registerTimeFilter
方法,发现TimeFilter
还是打印了日志
//@Configuration public class WebConfig extends WebMvcConfigurerAdapter {
//@Aspect //@Component public class GlobalControllerAspect {
Callable异步处理
在Controller
中,如果将一个Callable
作为方法的返回值,那么tomcat
线程池中的线程在响应结果时会新建一个线程执行该Callable
并将其返回结果返回给客户端
package top.zhenganwen.securitydemo.web.controller; import org.apache.commons.lang.RandomStringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; /** * @author zhenganwen * @date 2019/8/7 * @desc AsyncController */ @RestController @RequestMapping("/order") public class AsyncOrderController { private Logger logger = LoggerFactory.getLogger(getClass()); // 创建订单 @PostMapping public Callable<String> createOrder() { // 生成12位单号 String orderNumber = RandomStringUtils.randomNumeric(12); logger.info("[主线程]收到创建订单请求,订单号=>" + orderNumber); Callable<String> result = () -> { logger.info("[副线程]创建订单开始,订单号=>"+orderNumber); // 模拟创建订单逻辑 TimeUnit.SECONDS.sleep(3); logger.info("[副线程]创建订单完成,订单号=>" + orderNumber+",返回结果给客户端"); return orderNumber; }; logger.info("[主线程]已将请求委托副线程处理(订单号=>" + orderNumber + "),继续处理其它请求"); return result; } }
使用Postman
测试结果如下
控制台日志:
2019-08-21 21:10:39.059 INFO 17044 --- [nio-8080-exec-2] t.z.s.w.controller.AsyncOrderController : [主线程]收到创建订单请求,订单号=>719547514079 2019-08-21 21:10:39.059 INFO 17044 --- [nio-8080-exec-2] t.z.s.w.controller.AsyncOrderController : [主线程]已将请求委托副线程处理(订单号=>719547514079),继续处理其它请求 2019-08-21 21:10:39.063 INFO 17044 --- [ MvcAsync1] t.z.s.w.controller.AsyncOrderController : [副线程]创建订单开始,订单号=>719547514079 2019-08-21 21:10:42.064 INFO 17044 --- [ MvcAsync1] t.z.s.w.controller.AsyncOrderController : [副线程]创建订单完成,订单号=>719547514079,返回结果给客户端
观察可知主线程并没有执行Callable
下单任务而直接跑去继续监听其他请求了,下单任务由SpringMVC
新启了一个线程MvcAsync1
执行,Postman
的响应时间也是在Callable
执行完毕后得到了它的返回值。对于客户端来说,后端的异步处理是透明的,与同步时没有什么区别;但是对于后端来说,tomcat
监听请求的线程被占用的时间很短,大大提高了自身的并发能力
DeferredResult异步处理
Callable
异步处理的缺陷是,只能通过在本地新建副线程的方式进行异步处理,但现在随着微服务架构的盛行,我们经常需要跨系统的异步处理。例如在秒杀系统中,并发下单请求量较大,如果后端对每个下单请求做同步处理(即在请求线程中处理订单)后再返回响应结果,会导致服务假死(发送下单请求没有任何响应);这时我们可能会利用消息中间件,请求线程只负责监听下单请求,然后发消息给MQ,让订单系统从MQ中拉取消息(如单号)进行下单处理并将处理结果返回给秒杀系统;秒杀系统独立设一个监听订单处理结果消息的线程,将处理结果返回给客户端。如图所示
要实现类似上述的效果,需要使用Future
模式(可参考《Java多线程编程实战(设计模式篇)》),即我们可以设置一个处理结果凭证DeferredResult
,如果我们直接调用它的getResult
是获取不到处理结果的(会被阻塞,表现为虽然请求线程继续处理请求了,但是客户端仍在pending
,只有当某个线程调用它的setResult(result)
,才会将对应的result
响应给客户端
本例中,为降低复杂性,使用本地内存中的LinkedList
代替分布式消息中间件,使用本地新建线程代替订单系统线程,各类之间的关系如下
秒杀系统AsyncOrderController
package top.zhenganwen.securitydemo.web.async; import org.apache.commons.lang.RandomStringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; import java.util.concurrent.TimeUnit; /** * @author zhenganwen * @date 2019/8/7 * @desc AsyncController */ @RestController @RequestMapping("/order") public class AsyncOrderController { private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private DeferredResultHolder deferredResultHolder; @Autowired private OrderProcessingQueue orderProcessingQueue; // 秒杀系统下单请求 @PostMapping public DeferredResult<String> createOrder() { logger.info("【请求线程】收到下单请求"); // 生成12位单号 String orderNumber = RandomStringUtils.randomNumeric(12); // 创建处理结果凭证放入缓存,以便监听(订单系统向MQ发送的订单处理结果消息的)线程向凭证中设置结果,这会触发该结果响应给客户端 DeferredResult<String> deferredResult = new DeferredResult<>(); deferredResultHolder.placeOrder(orderNumber, deferredResult); // 异步向MQ发送下单消息,假设需要200ms new Thread(() -> { try { TimeUnit.MILLISECONDS.sleep(500); synchronized (orderProcessingQueue) { while (orderProcessingQueue.size() >= Integer.MAX_VALUE) { try { orderProcessingQueue.wait(); } catch (Exception e) { } } orderProcessingQueue.addLast(orderNumber); orderProcessingQueue.notifyAll(); } logger.info("向MQ发送下单消息, 单号: {}", orderNumber); } catch (InterruptedException e) { throw new RuntimeException(e); } }, "本地临时线程-向MQ发送下单消息") .start(); logger.info("【请求线程】继续处理其它请求"); // 并不会立即将deferredResult序列化成JSON并返回给客户端,而会等deferredResult的setResult被调用后,将传入的result转成JSON返回 return deferredResult; } }
两个MQ
package top.zhenganwen.securitydemo.web.async; import org.springframework.stereotype.Component; import java.util.LinkedList; /** * @author zhenganwen * @date 2019/8/22 * @desc OrderProcessingQueue 下单消息MQ */ @Component public class OrderProcessingQueue extends LinkedList<String> { }
package top.zhenganwen.securitydemo.web.async; import org.springframework.stereotype.Component; import java.util.LinkedList; /** * @author zhenganwen * @date 2019/8/22 * @desc OrderCompletionQueue 订单处理完成MQ */ @Component public class OrderCompletionQueue extends LinkedList<OrderCompletionResult> { }
package top.zhenganwen.securitydemo.web.async; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @author zhenganwen * @date 2019/8/22 * @desc OrderCompletionResult 订单处理完成结果信息,包括单号和是否成功 */ @Data @AllArgsConstructor @NoArgsConstructor public class OrderCompletionResult { private String orderNumber; private String result; }
凭证缓存
package top.zhenganwen.securitydemo.web.async; import org.hibernate.validator.constraints.NotBlank; import org.springframework.stereotype.Component; import org.springframework.web.context.request.async.DeferredResult; import javax.validation.constraints.NotNull; import java.util.HashMap; import java.util.Map; /** * @author zhenganwen * @date 2019/8/22 * @desc DeferredResultHolder 订单处理结果凭证缓存,通过凭证可以在未来的时间点获取处理结果 */ @Component public class DeferredResultHolder { private Map<String, DeferredResult<String>> holder = new HashMap<>(); // 将订单处理结果凭证放入缓存 public void placeOrder(@NotBlank String orderNumber, @NotNull DeferredResult<String> result) { holder.put(orderNumber, result); } // 向凭证中设置订单处理完成结果 public void completeOrder(@NotBlank String orderNumber, String result) { if (!holder.containsKey(orderNumber)) { throw new IllegalArgumentException("orderNumber not exist"); } DeferredResult<String> deferredResult = holder.get(orderNumber); deferredResult.setResult(result); } }
两个队列对应的两个监听
package top.zhenganwen.securitydemo.web.async; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationListener; import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; /** * @author zhenganwen * @date 2019/8/22 * @desc OrderProcessResultListener */ @Component public class OrderProcessingListener implements ApplicationListener<ContextRefreshedEvent> { private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired OrderProcessingQueue orderProcessingQueue; @Autowired OrderCompletionQueue orderCompletionQueue; @Autowired DeferredResultHolder deferredResultHolder; // spring容器启动或刷新时执行此方法 @Override public void onApplicationEvent(ContextRefreshedEvent event) { // 本系统(秒杀系统)启动时,启动一个监听MQ下单完成消息的线程 new Thread(() -> { while (true) { String finishedOrderNumber; OrderCompletionResult orderCompletionResult; synchronized (orderCompletionQueue) { while (orderCompletionQueue.isEmpty()) { try { orderCompletionQueue.wait(); } catch (InterruptedException e) { } } orderCompletionResult = orderCompletionQueue.pollFirst(); orderCompletionQueue.notifyAll(); } finishedOrderNumber = orderCompletionResult.getOrderNumber(); logger.info("收到订单处理完成消息,单号为: {}", finishedOrderNumber); deferredResultHolder.completeOrder(finishedOrderNumber, orderCompletionResult.getResult()); } },"本地监听线程-监听订单处理完成") .start(); // 假设是订单系统监听MQ下单消息的线程 new Thread(() -> { while (true) { String orderNumber; synchronized (orderProcessingQueue) { while (orderProcessingQueue.isEmpty()) { try { orderProcessingQueue.wait(); } catch (InterruptedException e) { } } orderNumber = orderProcessingQueue.pollFirst(); orderProcessingQueue.notifyAll(); } logger.info("收到下单请求,开始执行下单逻辑,单号为: {}", orderNumber); boolean status; // 模拟执行下单逻辑 try { TimeUnit.SECONDS.sleep(2); status = true; } catch (Exception e) { logger.info("下单失败=>{}", e.getMessage()); status = false; } // 向 订单处理完成MQ 发送消息 synchronized (orderCompletionQueue) { orderCompletionQueue.addLast(new OrderCompletionResult(orderNumber, status == true ? "success" : "error")); logger.info("发送订单完成消息, 单号: {}",orderNumber); orderCompletionQueue.notifyAll(); } } },"订单系统线程-监听下单消息") .start(); } }
测试
2019-08-22 13:22:05.520 INFO 21208 --- [nio-8080-exec-2] t.z.s.web.async.AsyncOrderController : 【请求线程】收到下单请求 2019-08-22 13:22:05.521 INFO 21208 --- [nio-8080-exec-2] t.z.s.web.async.AsyncOrderController : 【请求线程】继续处理其它请求 2019-08-22 13:22:06.022 INFO 21208 --- [ 订单系统线程-监听下单消息] t.z.s.web.async.OrderProcessingListener : 收到下单请求,开始执行下单逻辑,单号为: 104691998710 2019-08-22 13:22:06.022 INFO 21208 --- [地临时线程-向MQ发送下单消息] t.z.s.web.async.AsyncOrderController : 向MQ发送下单消息, 单号: 104691998710 2019-08-22 13:22:08.023 INFO 21208 --- [ 订单系统线程-监听下单消息] t.z.s.web.async.OrderProcessingListener : 发送订单完成消息, 单号: 104691998710 2019-08-22 13:22:08.023 INFO 21208 --- [本地监听线程-监听订单处理完成] t.z.s.web.async.OrderProcessingListener : 收到订单处理完成消息,单号为: 104691998710
configu reSync异步处理拦截、超时、线程池配置
在我们之前扩展WebMvcConfigureAdapter
的子类WebConfig
中可以通过重写configureAsyncSupport
方法对异步处理进行一些配置
registerCallableInterceptors & registerDeferredResultInterceptors
我们之前通过重写addInterceptors
方法注册的***对Callable
和DeferredResult
两种异步处理是无效的,如果想为这两者配置***需重写这两个方法
setDefaultTimeout
设置异步处理的超时时间,超过该时间就直接响应而不会等异步任务结束了
setTaskExecutor
SpringBoot
默认是通过新建线程的方式执行异步任务的,执行完后线程就被销毁了,要想通过复用线程(线程池)的方式执行异步任务,你可以通过此方法传入一个自定义的线程池
前后端分离
Swagger接口文档
swagger
项目能够根据我们所写的接口自动生成接口文档,方便我们前后端分离开发
依赖
<dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger2</artifactId> <version>2.7.0</version> </dependency> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger-ui</artifactId> <version>2.7.0</version> </dependency>
在启动类SecurityDemoApplication
上添加@@EnableSwagger2
注解开启接口文档自动生成开关,启动后访问localhost:8080/swagger-ui.html
常用注解
@ApiOperation
,注解在Controller方法上,用来描述方法的行为@GetMapping @JsonView(User.UserBasicView.class) @ApiOperation("用户查询服务") public List<User> query(String userName, UserQueryConditionDto userQueryConditionDto, Pageable pageable) {
@ApiModelProperty
,注解在Bean
的字段上,用来描述字段的含义@Data public class UserQueryConditionDto { @ApiModelProperty("用户名") private String username; @ApiModelProperty("密码") private String password; @ApiModelProperty("电话号码") private String phone; }
@ApiParam
,注解在Controller方法参数上,用来描述参数含义@DeleteMapping("/{id:\\d+}") public void delete(@ApiParam("用户id") @PathVariable Long id) { System.out.println(id); }
重启后接口文档会重新生成
WireMock
为了方便前后端并行开发,我们可以使用WireMock
作为虚拟接口服务器
在后端接口没开发完成时,前端可能会通过本地文件的方式伪造一些静态数据(例如JSON文件)作为请求的响应结果,这种方式在前端只有一种终端时是没问题的。但是当前端有多种,如PC、H5、APP、小程序等时,每种都去在自己的本地伪造数据,那么就显得有些重复,而且每个人按照自己的想法伪造数据可能会导致最终和真实接口无法无缝对接
这时wiremock
的出现就解决了这一痛点,wiremock
是用Java
开发的一个独立服务器,能够对外提供HTTP服务,我们可以通过wiremock
客户端去编辑/配置wiremock
服务器使它能像web
服务一样提供各种各样的接口,而且无需重新部署
下载 & 启动wiremock服务
wiremock可以以jar
方式运行,下载地址,下载完成后切换到其所在目录cmd
执行以下命令启动wiremock
服务器,--port=
指定运行端口
java -jar wiremock-standalone-2.24.1.jar --port=8062
依赖
引入wiremock
客户端依赖及其依赖的httpclient
<dependency> <groupId>com.github.tomakehurst</groupId> <artifactId>wiremock</artifactId> </dependency> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> </dependency>
由于在父工程中已经使用了依赖自动兼容,所以无需指定版本号。接着通过客户端API去编辑wiremock
服务器,为其添加接口
package top.zhenganwen.securitydemo.wiremock; import static com.github.tomakehurst.wiremock.client.WireMock.*; /** * @author zhenganwen * @date 2019/8/22 * @desc MockServer */ public class MockServer { public static void main(String[] args) { configureFor("127.0.0.1",8062); removeAllMappings(); // 移除所有旧的配置 // 添加配置,一个stub代表一个接口 stubFor( get(urlEqualTo("/order/1")). // 设置响应结果 willReturn( aResponse() .withBody("{\"id\":1,\"orderNumber\":\"545616156\"}") .withStatus(200) ) ); } }
你可以先将JSON数据存在
resources
中,然后通过ClassPathResource#getFile
和FileUtils#readLines
将数据读成字符串
访问localhost:8062/order/1
:
{ id: 1, orderNumber: "545616156" }
通过WireMock
API,你可以为虚拟服务器配置各种各样的接口服务
使用Spring Security开发基于表单的认证
Summary
Spring Security核心功能
- 认证(你是谁)
- 授权(你能干什么)
- 攻击防护(防止伪造身份,如果黑客能 伪造身份登录系统,上述两个功能就不起作用了)
本章内容
- Spring Security基本原理
- 实现用户名 + 密码认证
- 使用手机号 + 短信认证
Spring Security第一印象
Security
有一个默认的基础认证机制,我们注释掉配置项security.basic.enabled=false
(默认值为true
),重启查看日志会发现一条信息
Using default security password: f84e3dea-d231-47a2-b20a-48bac8ed5f1e
然后我们访问GET /user
,弹出登录框让我们登录,security
默认内置了一个用户名为user
,密码为上述日志中Using default security password: f84e3dea-d231-47a2-b20a-48bac8ed5f1e
的用户(该密码每次重启都会重新生成),我们使用这两者登录表单后页面重新跳转到了我们要访问的服务
formLogin
从本节开始我们将在security-browser
模块中编写我们的浏览器认证逻辑
我们可以通过添加配置类的方式(添加Configuration
,并扩展WebSecurityConfigureAdapter
)来配置验证方式、验证逻辑等,如设置验证方式为表单验证:
package top.zhenganwen.securitydemo.browser.config; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; /** * @author zhenganwen * @date 2019/8/22 * @desc SecurityConfig */ @Configuration public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http //设置认证方式为表单登录,若未登录而访问受保护的URL则跳转到表单登录页(security帮我们写了一个默认的登录页) .formLogin() // 添加其他配置 .and() // 验证方式配置结束,开始配置验证规则 .authorizeRequests() // 设置任何请求都需要通过认证 .anyRequest() .authenticated(); } }
访问/user
,跳转到默认的登录页/login
(该登录页和登录URL我们可以自定义),用户名user
,密码还是日志中的,登录成功跳转到/user
httpBasic
如果将认证方式由formLogin
改为httpBasic
就是security
最默认的配置(相当于引入security
依赖后什么都不配的效果),即弹出登录框
Spring Security基本原理
三种过滤器
如图所示,Spring Security
的核心其实就是一串过滤器链,所以它是非侵入式可插拔的。过滤器链中的过滤器分3种:
认证过滤器
XxxAuthenticationFilter
,如上图中标注为绿色的,它们的类名以AuthenticationFilter
结尾,作用是将登录的信息保存起来。这些过滤器是根据我们的配置动态生效的,如我们之前调用formLogin()
其实就是启用了UsernamePasswordAuthenticationFilter
,调用httpBaisc()
就是启用了BasicAuthenticationFilter
后面最贴近
Controller
的两个过滤器ExceptionTranslationFilter
和FilterSecurityInterceptor
包含了最核心的认证逻辑,默认是启用的,而且我们也无法禁用它们FilterSecurityInterceptor
,虽然命名以Interceptor
结尾,但其实还是一个Filter
,它是最贴近Controller
的一个过滤器,它会根据我们配置的拦截规则(哪些URL需要登录后才能访问,哪些URL需要某些特定的权限才能访问等)对访问相应URL的请求进行拦截,以下是它的部分源码public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { FilterInvocation fi = new FilterInvocation(request, response, chain); invoke(fi); } public void invoke(FilterInvocation fi) throws IOException, ServletException { ... InterceptorStatusToken token = super.beforeInvocation(fi); ... fi.getChain().doFilter(fi.getRequest(), fi.getResponse()); ... }
doFilter
就是真正调用我们的Controller
了(因为它是过滤器链的末尾),但在此之前它会调用beforeInvocation
对请求进行拦截校验是否有相关的身份和权限,校验失败对应会抛出未经认证异常(Unauthenticated
)和未经授权异常(Unauthorized
),这些异常会被ExceptionTranslationFilter
捕获到ExceptionTranslationFilter
,顾名思义就是解析异常的,其部分源码如下public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; try { chain.doFilter(request, response); } catch (Exception ex) { // Try to extract a SpringSecurityException from the stacktrace ... } }
它调用
chain.doFilter
其实就是去到了FilterSecurityInterceptor
,它会对FilterSecurityInterceptor.doFilter
中抛出的SpringSecurityException
异常进行捕获并解析处理,例如FilterSecurityInterceptor
抛出了Unauthenticated
异常,那么ExceptionTranslationFilter
就会重定向到登录页或是弹出登录框(取决于我们配置了什么认证过滤器),当我们成功登录后,认证过滤又会重定向到我们最初要访问的URL
断点调试
我们可以通过断点调试的方式来验证上述所说,将验证方式设为formLogin
,然后在3个过滤器和Controller
中分别打断点,重启服务访问/user
自定义用户认证逻辑
处理用户信息获取逻辑——UserDetailsService
到此为止我们登录都是通过user
和启动日志生成的密码,这是security
内置了一个user
用户。实际项目中我们一般有一个专门存放用户的表,会通过jdbc
或从其他存储系统读取用户信息,这时就需要我们自定义读取用户信息的逻辑,通过实现UserDetailsService
接口即可告诉security
从如何获取用户信息
package top.zhenganwen.securitydemo.browser.config; import org.hibernate.validator.constraints.NotBlank; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import java.util.Objects; /** * @author zhenganwen * @date 2019/8/23 * @desc CustomUserDetailsService */ @Component public class CustomUserDetailsService implements UserDetailsService { private Logger logger = LoggerFactory.getLogger(getClass()); @Override public UserDetails loadUserByUsername(@NotBlank String username) throws UsernameNotFoundException { logger.info("登录用户名: " + username); // 实际项目中你可以调用Dao或Repository来查询用户是否存在 if (Objects.equals(username, "admin") == false) { throw new UsernameNotFoundException("用户名不存在"); } // 在查询到用户后需要将相关信息包装成一个UserDetails实例返回给security,这里的User是security提供的一个实现 // 第三个参数需要传一个权限集合,这里使用了一个security提供的工具类将用分号分隔的权限字符串转成权限集合,本来应该从用户权限表查询的 return new org.springframework.security.core.userdetails.User( "admin","123456", AuthorityUtils.commaSeparatedStringToAuthorityList("user,admin") ); } }
重启服务后只能通过admin,123456
来登录了
处理用户校验逻辑——UserDetails
我们来看一下UserDetails
接口源码
public interface UserDetails extends Serializable { Collection<? extends GrantedAuthority> getAuthorities(); // 用来和用户登录时填写的密码进行比对 String getPassword(); String getUsername(); // 账户是否是非过期的 boolean isAccountNonExpired(); // 账户是否是非冻结的 boolean isAccountNonLocked(); // 密码是否是非过期的,有些安全性较高的系统需要账户每隔一段时间更换密码 boolean isCredentialsNonExpired(); // 账户是否可用,可以对应逻辑删除字段 boolean isEnabled(); }
在重写以is
开头的四个方法时,如果无需相应判断,则返回true
即可,例如对应用户表的实体类如下
@Data public class User{ private Long id; private String username; private String password; private String phone; private int deleted; //0-"正常的",1-"已删除的" private int accountNonLocked; //0-"账号未被冻结",1-"账号已被冻结" }
为了方便,我们可以直接使用实体类实现UserDetails
接口
@Data public class User implements UserDetails{ private Long id; private String uname; private String pwd; private String phone; private int deleted; private int accountNonLocked; public String getPassword(){ return pwd; } public String getUsername(){ return uname; } public boolean isAccountNonExpired(){ return true; } public boolean isAccountNonLocked(){ return accountNonLocked == 0; } public boolean isCredentialsNonExpired(){ return true; } public boolean isEnabled(){ return deleted == 0; } }
处理密码加密解密——PasswordEncoder
用户表中的密码字段一般不会存放密码的明文而是存放加密后的密文,这时我们就需要PasswordEncoder
的支持了:
public interface PasswordEncoder { String encode(CharSequence rawPassword); boolean matches(CharSequence rawPassword, String encodedPassword); }
我们在插入用户到数据库时,需要调用encode
对明文密码加密后再插入;在用户登录时,security
会调用matches
将我们从数据库查出的密文面和用户提交的明文密码进行比对。
security
为我们提供了一个该接口的非对称加密(对同一明文密码,每次调用encode
得到的密文都是不一样的,只有通过matches
来比对明文和密文是否对应)实现类BCryptPasswordEncoder
,我们只需配置一个该类的Bean
,security
就会认为我们返回的UserDetails
的getPassword
返回的密码是通过该Bean
加密过的(所以在插入用户时要注意调用该Bean
的encode
对密码加密一下在插入数据库)
@Configuration public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter { @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Component public class CustomUserDetailsService implements UserDetailsService { @Autowired BCryptPasswordEncoder passwordEncoder; private Logger logger = LoggerFactory.getLogger(getClass()); @Override public UserDetails loadUserByUsername(@NotBlank String username) throws UsernameNotFoundException { logger.info("登录用户名: " + username); // 实际项目中你可以调用Dao或Repository来查询用户是否存在 if (Objects.equals(username, "admin") == false) { throw new UsernameNotFoundException("用户名不存在"); } // 假设查出来的密码如下 String pwd = passwordEncoder.encode("123456"); return new org.springframework.security.core.userdetails.User( "admin", pwd, AuthorityUtils.commaSeparatedStringToAuthorityList("user,admin") ); } }
BCryptPasswordEncoder
不一定只能用于密码的加密和校验,日常开发中涉及到加密的功能我们都能使用它的encode
方法,也能使用matches
方法比对某密文是否是某明文加密后的结果
个性化用户认证流程
自定义登录页面
在formLogin()
后使用loginPage()
就能指定登录的页面,同时要记得将该URL的拦截放开;UsernamePasswordAuthenticationFilter
默认拦截提交到/login
的POST
请求并获取登录信息,如果你想表单填写的action
不为/post
,那么可以配置loginProcessingUrl
使UsernamePasswordAuthenticationFilter
与之对应
@Configuration public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter { @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { http //设置认证方式为表单登录,若未登录而访问受保护的URL则跳转到表单登录页(security帮我们写了一个默认的登录页) .formLogin() .loginPage("/sign-in.html").loginProcessingUrl("/auth/login") .and() // 验证方式配置结束,开始配置验证规则 .authorizeRequests() // 登录页面不需要拦截 .antMatchers("/sign-in.html").permitAll() // 设置任何请求都需要通过认证 .anyRequest().authenticated(); } }
自定义登录页:security-browser/src/main/resource/resources/sign-in.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>登录页面</title> </head> <body> <form action="/auth/login" method="post"> 用户名: <input type="text" name="username"> 密码: <input type="password" name="password"> <button type="submit">提交</button> </form> </body> </html>
重启后访问GET /user
,调整到了我们写的登录页sign-in.html
,填写admin,123456
登录,发现还是报错如下
There was an unexpected error (type=Forbidden, status=403). Invalid CSRF Token 'null' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.
这是因为security
默认启用了跨站伪造请求防护CSRF(例如使用HTTP客户端Postman
也可以发出这样的登录请求),我们先禁用它
http .formLogin() .loginPage("/sign-in.html").loginProcessingUrl("/auth/login") .and() .authorizeRequests() .antMatchers("/sign-in.html").permitAll() .anyRequest().authenticated() .and() .csrf().disable()
再重启访问GET /user
,跳转登录后,自动跳转回/user
,自定义登录页成功
REST登录逻辑
由于我们是基于REST
的服务,所以如果是非浏览器请求,我们应该返回401状态码告诉客户端需要认证,而不是重定向到登录页
这时我们就不能将loginPage
写成登录页路径了,而应该重定向到一个Controller
,由Controller
判断用户是在浏览器访问页面时跳转过来的还是非浏览器如安卓访问REST服务时跳转过来,如果是前者那么就重定向到登录页,如果是后者就响应401状态码和JSON消息
package top.zhenganwen.securitydemo.browser; import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.security.web.DefaultRedirectStrategy; import org.springframework.security.web.RedirectStrategy; import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.savedrequest.SavedRequest; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import top.zhenganwen.securitydemo.browser.support.SimpleResponseResult; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * @author zhenganwen * @date 2019/8/23 * @desc AuthenticationController */ @RestController public class BrowserSecurityController { private Logger logger = LoggerFactory.getLogger(getClass()); // security会将跳转前的请求存储在session中 private RequestCache requestCache = new HttpSessionRequestCache(); private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); @RequestMapping("/auth/require") // 该注解可设置响应状态码 @ResponseStatus(code = HttpStatus.UNAUTHORIZED) public SimpleResponseResult requireAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException { // 从session中取出跳转前用户访问的URL SavedRequest savedRequest = requestCache.getRequest(request, response); if (savedRequest != null) { String redirectUrl = savedRequest.getRedirectUrl(); logger.info("引发跳转到/auth/login的请求是: {}", redirectUrl); if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) { // 如果用户是访问html页面被FilterSecurityInterceptor拦截从而跳转到了/auth/login,那么就重定向到登录页面 redirectStrategy.sendRedirect(request, response, "/sign-in.html"); } } // 如果不是访问html而被拦截跳转到了/auth/login,则返回JSON提示 return new SimpleResponseResult("用户未登录,请引导用户至登录页"); } }
@Configuration public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter { @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { http .formLogin() .loginPage("/auth/require").loginProcessingUrl("/auth/login") .and() .authorizeRequests() .antMatchers("/auth/require").permitAll() .antMatchers("/sign-in.html").permitAll() .anyRequest().authenticated() .and() .csrf().disable(); } }
重构——配置代替hardcode
由于我们的security-browser
模块是作为可复用模块来开发的,应该支持自定义配置,例如其他应用引入我们的security-browser
模块之后,应该能配置他们自己的登录页,如果他们没有配置那就使用我们默认提供的sign-in.html
,要想做到这点,我们需要提供一些配置项,例如别人引入我们的security-browser
之后通过添加demo.security.browser.loginPage=/login.html
就能将他们项目的login.html
替换掉我们的sign-in.html
由于后续security-app
也可能会需要支持类似的配置,因此我们在security-core
中定义一个总的配置类来封装各模块的不同配置项
security-core
中的类:
package top.zhenganwen.security.core.properties; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; /** * @author zhenganwen * @date 2019/8/23 * @desc SecurityProperties 封装整个项目各模块的配置项 */ @Data @ConfigurationProperties(prefix = "demo.security") public class SecurityProperties { private BrowserProperties browser = new BrowserProperties(); }
package top.zhenganwen.security.core.properties; import lombok.Data; /** * @author zhenganwen * @date 2019/8/23 * @desc BrowserProperties 封装security-browser模块的配置项 */ @Data public class BrowserProperties { private String loginPage = "/sign-in.html"; //提供一个默认的登录页 }
package top.zhenganwen.security.core; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; import top.zhenganwen.security.core.properties.SecurityProperties; /** * @author zhenganwen * @date 2019/8/23 * @desc SecurityCoreConfig */ @Configuration // 启用在启动时将application.properties中的demo.security前缀的配置项注入到SecurityProperties中 @EnableConfigurationProperties(SecurityProperties.class) public class SecurityCoreConfig { }
然后在security-browser
中将SecurityProperties
注入进来,将重定向到登录页的逻辑依赖配置文件中的demo.security.browser.loginPage
@RestController public class BrowserSecurityController { private Logger logger = LoggerFactory.getLogger(getClass()); private RequestCache requestCache = new HttpSessionRequestCache(); private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); @Autowired private SecurityProperties securityProperties; @RequestMapping("/auth/require") @ResponseStatus(code = HttpStatus.UNAUTHORIZED) public SimpleResponseResult requireAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException { SavedRequest savedRequest = requestCache.getRequest(request, response); if (savedRequest != null) { String redirectUrl = savedRequest.getRedirectUrl(); logger.info("引发跳转到/auth/login的请求是: {}", redirectUrl); if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) { redirectStrategy.sendRedirect(request, response, securityProperties.getBrowser().getLoginPage()); } } return new SimpleResponseResult("用户未登录,请引导用户至登录页"); } }
将不拦截的登录页URL设置为动态的
@Configuration public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter { @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Autowired private SecurityProperties securityProperties; @Override protected void configure(HttpSecurity http) throws Exception { http .formLogin() .loginPage("/auth/require").loginProcessingUrl("/auth/login") .and() .authorizeRequests() .antMatchers("/auth/require").permitAll() // 将不拦截的登录页URL设置为动态的 .antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll() .anyRequest().authenticated() .and() .csrf().disable(); } }
现在,我们将security-demo
模块当做第三方应用,使用可复用的security-browser
首先,要将security-demo
模块的启动类SecurityDemoApplication
移到top.zhenganwen.securitydemo
包下,确保能够扫描到security-core
下的top.zhenganwen.securitydemo.core.SecurityCoreConfig
和security-browser
下的top.zhenganwen.securitydemo.browser.SecurityBrowserConfig
然后,在security-demo
的application.properties
中添加配置项demo.security.browser.loginPage=/login.html
并在resources
下新建resources
文件夹和其中的login.html
:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <h1>Security Demo应用的登录页面</h1> <form action="/auth/login" method="post"> 用户名: <input type="text" name="username"> 密码: <input type="password" name="password"> <button type="submit">提交</button> </form> </body> </html>
重启服务,访问/user.html
发现跳转到了login.html
;注释掉demo.security.browser.loginPage=/login.html
,再重启服务访问/user.html
发现跳转到了sign-in.html
,重构成功!
自定义登录成功处理——AuthenticationSuccessHandler
security
处理登录成功的逻辑默认是重定向到之前被拦截的请求,但是对于REST服务来说,前端可能是AJAX请求登录,希望获取的响应是用户的相关信息,这时你给他重定向显然不合适。要想自定义登录成功后的处理,我们需要实现AuthenticationSuccessHandler
接口
package top.zhenganwen.securitydemo.browser.config; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * @author zhenganwen * @date 2019/8/24 * @desc CustomAuthenticationSuccessHandler */ @Component("customAuthenticationSuccessHandler") public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler { private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private ObjectMapper objectMapper; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException , ServletException { logger.info("用户{}登录成功", authentication.getName()); response.setContentType("application/json;charset=utf-8"); response.getWriter().write(objectMapper.writeValueAsString(authentication)); response.getWriter().flush(); } }
在登录成功后,我们会拿到一个Authentication
,这也是security
的一个核心接口,作用是封装用户的相关信息,这里我们将其转成JSON串响应给前端看一下它包含了哪些内容
我们还需要通过successHandler()
将其配置到HttpSecurity
中以使之生效(替代默认的登录成功处理逻辑):
@Configuration public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter { @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Autowired private SecurityProperties securityProperties; @Autowired private AuthenticationSuccessHandler customAuthenticationSuccessHandler; @Override protected void configure(HttpSecurity http) throws Exception { http .formLogin() .loginPage("/auth/require").loginProcessingUrl("/auth/login") .successHandler(customAuthenticationSuccessHandler) .and() .authorizeRequests() .antMatchers("/auth/require").permitAll() .antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll() .anyRequest().authenticated() .and() .csrf().disable(); } }
重启服务,访问/login.html
并登录:
{ authorities: [ { authority: "admin" }, { authority: "user" } ], details: { remoteAddress: "0:0:0:0:0:0:0:1", sessionId: "3BA37577BAC493D0FE1E07192B5524B1" }, authenticated: true, principal: { password: null, username: "admin", authorities: [ { authority: "admin" }, { authority: "user" } ], accountNonExpired: true, accountNonLocked: true, credentialsNonExpired: true, enabled: true }, credentials: null, name: "admin" }
可以发现Authentication
包含了以下信息
authorities
,权限,对应UserDetials
中getAuthorities()
的返回结果details
,回话,客户端的IP以及本次回话的SESSIONIDauthenticated
,是否通过认证principle
,对应UserDetailsService
中loadUserByUsername
返回的UserDetails
credentials
,密码,security
默认做了处理,不将密码返回给前端name
,用户名
这里因为我们是表单登录,所以返回的是以上信息,之后我们做第三方登录如微信、QQ,那么Authentication
包含的信息就可能不一样了,也就是说重写的onAuthenticationSuccess
方法的入参Authentication
会根据登录方式的不同传给我们不同的Authentication
实现类对象
自定义登录失败处理——AuthenticationFailureHandler
与登录成功处理对应,自然也可以自定义登录失败处理
package top.zhenganwen.securitydemo.browser.config; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * @author zhenganwen * @date 2019/8/24 * @desc CustomAuthenticationFailureHandler */ @Component("customAuthenticationFailureHandler") public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private ObjectMapper objectMapper; @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { logger.info("登录失败=>{}", exception.getMessage()); response.setContentType("application/json;charset=utf-8"); response.getWriter().write(objectMapper.writeValueAsString(exception)); response.getWriter().flush(); } }
@Configuration public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter { @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Autowired private SecurityProperties securityProperties; @Autowired private AuthenticationSuccessHandler customAuthenticationSuccessHandler; @Autowired private AuthenticationFailureHandler customAuthenticationFailureHandler; @Override protected void configure(HttpSecurity http) throws Exception { http .formLogin() .loginPage("/auth/require") .loginProcessingUrl("/auth/login") .successHandler(customAuthenticationSuccessHandler) .failureHandler(customAuthenticationFailureHandler) .and() .authorizeRequests() .antMatchers("/auth/require").permitAll() .antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll() .anyRequest().authenticated() .and() .csrf().disable(); } }
访问/login.html
输入错误的密码登录:
{ cause: null, stackTrace: [...], localizedMessage: "坏的凭证", message: "坏的凭证", suppressed: [ ] }
重构
为了使security-browser
成为可复用的模块,我们应该将登录成功/失败处理策略抽离出去,让第三方应用自由选择,这时我们又可以新增一个配置项demo.security.browser.loginProcessType
切换到security-core
:
package top.zhenganwen.security.core.properties; /** * @author zhenganwen * @date 2019/8/24 * @desc LoginProcessTypeEnum */ public enum LoginProcessTypeEnum { // 重定向到之前的请求页或登录失败页 REDIRECT("redirect"), // 登录成功返回用户信息,登录失败返回错误信息 JSON("json"); private String type; LoginProcessTypeEnum(String type) { this.type = type; } }
@Data public class BrowserProperties { private String loginPage = "/sign-in.html"; private LoginProcessTypeEnum loginProcessType = LoginProcessTypeEnum.JSON; //默认返回JSON信息 }
重构登录成功/失败处理器,其中SavedRequestAwareAuthenticationSuccessHandler
和SimpleUrlAuthenticationFailureHandler
就是security
提供的默认的登录成功(跳转到登录之前请求的页面)和登录失败(跳转到异常页)的处理器
package top.zhenganwen.securitydemo.browser.config; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.stereotype.Component; import top.zhenganwen.security.core.properties.LoginProcessTypeEnum; import top.zhenganwen.security.core.properties.SecurityProperties; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * @author zhenganwen * @date 2019/8/24 * @desc CustomAuthenticationSuccessHandler */ @Component("customAuthenticationSuccessHandler") public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private ObjectMapper objectMapper; @Autowired private SecurityProperties securityProperties; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException , ServletException { if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) { // 重定向到缓存在session中的登录前请求的URL super.onAuthenticationSuccess(request, response, authentication); return; } logger.info("用户{}登录成功", authentication.getName()); response.setContentType("application/json;charset=utf-8"); response.getWriter().write(objectMapper.writeValueAsString(authentication)); response.getWriter().flush(); } }
package top.zhenganwen.securitydemo.browser.config; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; import top.zhenganwen.security.core.properties.LoginProcessTypeEnum; import top.zhenganwen.security.core.properties.SecurityProperties; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * @author zhenganwen * @date 2019/8/24 * @desc CustomAuthenticationFailureHandler */ @Component("customAuthenticationFailureHandler") public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private ObjectMapper objectMapper; @Autowired private SecurityProperties securityProperties; @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) { super.onAuthenticationFailure(request, response, exception); return; } logger.info("登录失败=>{}", exception.getMessage()); response.setContentType("application/json;charset=utf-8"); response.getWriter().write(objectMapper.writeValueAsString(exception)); response.getWriter().flush(); } }
访问/login.html
,分别进行登录成功和登录失败测试,返回JSON响应
在security-demo
中
application.properties
中添加demo.security.browser.loginProcessType=redirect
新建
/resources/resources/index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <h1>Spring Demo应用首页</h1> </body> </html>
新建
/resources/resources/401.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <h1>login fail!</h1> </body> </html>
重启服务,登录成功跳转到index.html
,登录失败跳转到401.html
认证流程源码级详解
经过上述两节,我们已经会使用security
的一些基础功能了,但都是碎片化的,对整体流程的把握还很模糊。知其然还要知其所以然,我们需要分析在登录时security
都帮我们做了哪些事
认证处理流程
上图是登录处理的大致流程,登录请求的过滤器XxxAutenticationFilter
在拦截到登录请求后会见登录信息封装成一个authenticated=false
的Authentication
传给AuthenticationManager
让帮忙校验,AuthenticationManager
本身也不会做校验逻辑,会委托AuthenticationProvider
帮忙校验,AuthenticationProvider
会在校验过程中抛出校验失败异常或校验通过返回一个新的带有UserDetials
的Authentication
返回,请求过滤器收到XxxAuthenticationFilter
之后会调用登录成功处理器执行登录成功逻辑
我们以用户名密码表单登录方式来断点调试逐步分析一下校验流程,其他的登录方式也就大同小异了
认证结果如何在多个请求之间共享
要想在多个请求之间共享数据,需要借助session
,接下来我们看一下security
将什么东西放到了session
中,又在什么时候会从session
读取
上节说道在AbstractAuthenticationProcessingFilter
的``doFilter方法中,校验成功之后会调用
successfulAuthentication(request, response, chain, authResult)`,我们来看一下这个方法干了些什么
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { if (logger.isDebugEnabled()) { logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult); } SecurityContextHolder.getContext().setAuthentication(authResult); ... successHandler.onAuthenticationSuccess(request, response, authResult); }
可以发现,在调用登录成功处理器的处理逻辑之前,调用了一下SecurityContextHolder.getContext().setAuthentication(authResult)
,查看可知SecurityContextHolder.getContext()
就是获取当前线程绑定的SecurityContext
(可以看做是一个线程变量,作用域为线程的生命周期),而SecurityContext
其实就是对Authentication
的一层包装
public class SecurityContextHolder { private static SecurityContextHolderStrategy strategy; public static SecurityContext getContext() { return strategy.getContext(); } }
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy { private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<SecurityContext>(); public SecurityContext getContext() { SecurityContext ctx = contextHolder.get(); if (ctx == null) { ctx = createEmptyContext(); contextHolder.set(ctx); } return ctx; } }
public interface SecurityContext extends Serializable { Authentication getAuthentication(); void setAuthentication(Authentication authentication); }
public class SecurityContextImpl implements SecurityContext { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; public Authentication getAuthentication() { return authentication; } public int hashCode() { if (this.authentication == null) { return -1; } else { return this.authentication.hashCode(); } } public void setAuthentication(Authentication authentication) { this.authentication = authentication; } ... }
那么将Authentication
保存到当前线程的SecurityContext
中的用意是什么呢?
这就涉及到了另外一个特别的过滤器SecurityContextPersistenceFilter
,它位于security
的整个过滤器链的最前端:
private SecurityContextRepository repo; // 请求到达的第一个过滤器 public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { ... HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,response); // 从Session中获取SecurityContext,未登录时获取的则是空 SecurityContext contextBeforeChainExecution = repo.loadContext(holder); try { // 将SecurityContext保存到当前线程的ThreadLocalMap中 SecurityContextHolder.setContext(contextBeforeChainExecution); // 执行后续过滤器和Controller方法 chain.doFilter(holder.getRequest(), holder.getResponse()); } // 在请求响应时经过的最后一个过滤器 finally { // 从当前线程获取SecurityContext SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext(); SecurityContextHolder.clearContext(); // 将SecurityContext持久化到Session repo.saveContext(contextAfterChainExecution, holder.getRequest(),holder.getResponse()); ... } }
public class HttpSessionSecurityContextRepository implements SecurityContextRepository { ... public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) { HttpServletRequest request = requestResponseHolder.getRequest(); HttpServletResponse response = requestResponseHolder.getResponse(); HttpSession httpSession = request.getSession(false); SecurityContext context = readSecurityContextFromSession(httpSession); ... return context; } ... }
获取认证用户信息
在我们的代码中可以通过静态方法SecurityContextHolder.getContext().getAuthentication
来获取用户信息,或者可以直接在Controller
入参声明Authentication
,security
会帮我们自动注入,如果只想获取Authentication
中的UserDetails
对应的部分,则可使用@AuthenticationPrinciple UserDetails currentUser
@GetMapping("/info1") public Object info1() { return SecurityContextHolder.getContext().getAuthentication(); } @GetMapping("/info2") public Object info2(Authentication authentication) { return authentication; }
GET /user/info1
{ authorities: [ { authority: "admin" }, { authority: "user" } ], details: { remoteAddress: "0:0:0:0:0:0:0:1", sessionId: "24AE70712BB99A969A5C56907C39C20E" }, authenticated: true, principal: { password: null, username: "admin", authorities: [ { authority: "admin" }, { authority: "user" } ], accountNonExpired: true, accountNonLocked: true, credentialsNonExpired: true, enabled: true }, credentials: null, name: "admin" }
@GetMapping("/info3") public Object info3(@AuthenticationPrincipal UserDetails currentUser) { return currentUser; }
GET /user/info3
{ password: null, username: "admin", authorities: [ { authority: "admin" }, { authority: "user" } ], accountNonExpired: true, accountNonLocked: true, credentialsNonExpired: true, enabled: true }
实现图形验证码功能
功能实现
由于图形验证码是通用功能,所以我们将相关逻辑写在security-code
中
首先,将图形、图形中的验证码、验证码过期时间封装在一起
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); } }
然后提供一个生成验证码的接口
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
的配置类中将生成验证码的接口权限放开:
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
中添加验证码输入框:
<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
,验证码生成如下:
接下来我们编写验证码校验逻辑,由于security
并未提供验证码校验对应的过滤器,因此我们需要自定义一个并将其插入到UsernamePasswordFilter
之前:
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); } }
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
@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如下
{"cause":null,"stackTrace":[...],"localizedMessage":"验证码不能为空","message":"验证码不能为空","suppressed":[]}{"cause":null,"stackTrace":[...],"localizedMessage":"坏的凭证","message":"坏的凭证","suppressed":[]}
发现连着返回了两个exception
的JSON串,且是一前以后返回的(两个JSON串是连着的,中间没有任何符号),这是因为我们在VerifyCodeAuthenticationFilter
中调用customAuthenticationFailureHandler
进行认证失败处理之后,接着执行了doFilter
,而后的UsernamePasswordAuthenticationFilter
也会拦截登录请求/auth/login
,在校验的过程中捕获到BadCredentialsException
,又调用customAuthenticationFailureHandler
返回了一个exception
JSON串
这里有两点需要优化
返回的异常信息不应该包含堆栈
在
CustomAuthenticationFailureHandler
中返回从exception
中提取的异常信息,而不要直接返回exception
// response.getWriter().write(objectMapper.writeValueAsString(exception)); response.getWriter().write(objectMapper.writeValueAsString(new SimpleResponseResult(exception.getMessage())));
在
VerifyCodeAuthenticationFilter
发现认证失败异常并调用认证失败处理器处理后,应该return
一下,没有必要再走后续的过滤器了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);
重新测试
{ content: "验证码不能为空" }
接着测试验证码,填入admin,123456
和图形验证码后登陆,登陆成功,认证成功处理器返回Authentication
{ 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" }
重构图形验证码功能
至此,图形验证码的功能我们已经基本实现完了,但是作为高级工程师我们不应该满足于此,在实现功能之余还应该想想如何重构代码使该功能可重用,当别人需要不同尺寸、不同数量验证字符、不同验证逻辑时,也能够复用我们的代码
图形验证码基本参数可配置
如图形的长宽像素、验证码字符数、验证码有效期持续时间
一般系统的配置生效机制如下,我们作为被依赖的模块需要提供一个常用的默认配置,依赖我们的应用可以自己添加配置项来覆盖这个默认配置,最后在应用运行时还可以通过在请求中附带参数来动态切换配置
security-core
添加配置类
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; }
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(); }
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(); }
在生成验证接口中,将对应参数改为动态读取
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-demo
的application.properties
中添加配置项
demo.security.code.image.strLength=6
测试请求参数级配置覆盖应用级配置
demo.security.code.image.width=100
验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=200" alt="">
访问/login.html
,发现图形宽度200
,验证码字符数为6
,测试成功
验证码认证过滤器拦截的接口可配
现在我们的VerifyCodeFilter
仅拦截登录请求并进行验证码校验,可能别的接口也需要验证码才能调用(也许是为了非法重复请求),那么这时我们需要支持应用能够动态地配置需要进行验证码校验的接口,例如
demo.security.code.image.url=/user,/user/*
表示请求/user
和/user/*
之前都需要进行验证码校验
于是我们新增一个可配置拦截URI的属性
@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需要检验验证码,验证失败则抛出异常留给认证失败处理器处理,校验成功则跳出遍历循环直接放行
@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
原有实现的思想一种很常用设计模式,在需要扩展功能的时候无需更改原有代码,而只需添加一个实现类,以增量的方式适应变化
首先我们将生成图形验证码的逻辑抽象成接口
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
中的生成图形验证码的方法作为该接口的默认实现
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
会在所有未标注@ConditionOnMissingBean
的bean
都被实例化注入到容器中后,判断容器中是否存在id为imageCodeGenerator
的bean
,如果不存在才会进行实例化并作为id为imageCodeGenerator
的bean
被使用
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; } }
验证码生成接口改为依赖验证码生成器接口来生成验证码(面向抽象编程以适应变化):
@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
中新增一个自定义的图形验证码生成器来替换默认的:
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
调用图形验证码生成器接口生成图形验证码时如果走的是我们这个自定义的图形验证码生成器就会抛出异常。注意@Component
的value
属性要和@ConditionOnMissingBean
的name
属性一致才能实现替换
实现记住我功能
需求
有时用户希望在填写登录表单时勾选一个“记住我”选框,在登陆后的一段时间内可以无需登录即可访问受保护的URL
实现
本节,我们就来实现以下该功能:
首先页面需要一个“记住我”选框,选框的
name
属性需为remember-me
(可自定义配置),value
属性为true
<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>
在数据源对应的数据库中创建一张表
persistent_logins
,表创建语句在JdbcTokenRepositoryImpl
的变量CREATE_TABLE_SQL
中create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, " + "token varchar(64) not null, last_used timestamp not null)
在
seurity
配置类中增加“记住我”的相关配置,这里因为Cookie
受限于浏览器,所有我们配在security-browser
模块中,如下rememberMe()
部分@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(); }
测试
未登录访问
/user
提示需要登录,登录/login.html
后访问/user
可访问成功,查看数据库表persistent_logins
,发现新增了一条记录。关闭服务模拟Session
关闭(因为Session
是保存服务端的,关闭服务端比关闭浏览器更能保证Session
关闭)。重启服务,未登录访问受保护的/user
,发现可以直接访问
源码分析
上图是开启了“记住我”功能后,用户首次登录的序列图,在AbstractAuthenticationProcessingFilter
中校验用户名密码成功之后在方法的末尾会调用successfulAuthentication
,查看其源码(部分省略):
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()
中
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
会拦截到请求:
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
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
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动态获取用户输入的手机号
<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
首先要新建一个类封装短信验证码的相关属性:
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
重命名为VerifyCode
让ImageCode
继承以复用代码
@Data @AllArgsConstructor @NoArgsConstructor public class VerifyCode { protected String code; protected LocalDateTime expireTime; public boolean isExpired() { return LocalDateTime.now().isAfter(expireTime); } }
@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
,重构验证码生成器类结构:
package top.zhenganwen.security.core.verifycode.generator; import top.zhenganwen.security.core.verifycode.dto.VerifyCode; public interface VerifyCodeGenerator<T extends VerifyCode> { /** * 生成验证码 * @return */ T generateVerifyCode(); }
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()); } ... }
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; } }
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
中并调用短信服务提供商的接口将短信发送出去,由于将来依赖我们的应用可能会配置不同的短信服务提供商接口,为了保证代码的可扩展性我们需要将短信发送这一行为抽象成接口并提供一个默认可被覆盖的实现,这样依赖我们的应用就可以通过注入一个新的实现来启用它们的短信发送逻辑
package top.zhenganwen.security.core.verifycode; public interface SmsCodeSender { /** * 根据手机号发送短信验证码 * @param smsCode * @param phoneNumber */ void send(String smsCode, String phoneNumber); }
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); } }
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(); } }
重构配置类
package top.zhenganwen.security.core.properties; import lombok.Data; @Data public class SmsCodeProperties { // 短信验证码数字个数,默认4个数字 private int strLength = 4; // 有效时间,默认60秒 private int durationSeconds = 60; }
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); } }
package top.zhenganwen.security.core.properties; import lombok.Data; @Data public class VerifyCodeProperties { private ImageCodeProperties image = new ImageCodeProperties(); private SmsCodeProperties sms = new SmsCodeProperties(); }
发送短信验证码接口
@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
的访问权限放开:
.authorizeRequests() .antMatchers( "/auth/require", securityProperties.getBrowser().getLoginPage(), "/verifyCode/**").permitAll() .anyRequest().authenticated()
访问/login.html
,点击点击发送
超链接,后台输出如下:
向手机号12345678912发送短信验证码1220
重构——模板方法 & 依赖查找
现在我们的VerifyCodeController
中的两个方法imageCode
和smsCode
的主干流程是一致的:
- 生成验证码
- 保存验证码,如保存到
Session
中、redis
中等等 - 发送验证码给用户
这种情况下,我们可以应用模板方法设计模式(可看考我的另一篇文章《图解设计模式》),重构后的类图如下所示:
常量类
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"; }
public enum VerifyCodeTypeEnum { IMAGE("image"),SMS("sms"); private String type; public String getType() { return type; } VerifyCodeTypeEnum(String type) { this.type = type; } }
验证码发送处理器——模板方法 & 接口隔离 & 依赖查找
public interface VerifyCodeProcessor { /** * 发送验证码逻辑 * 1. 生成验证码 * 2. 保存验证码 * 3. 发送验证码 * @param request 封装request和response的工具类,用它我们就不用每次传{@link javax.servlet.http.HttpServletRequest}和{@link javax.servlet.http.HttpServletResponse}了 */ void sendVerifyCode(ServletWebRequest request); }
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); }
@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()); } } }
@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("手机号码不能为空"); } } }
验证码生成器
public interface VerifyCodeGenerator<T extends VerifyCode> { /** * 生成验证码 * @return */ T generateVerifyCode(); }
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); } }
@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())); } }
验证码发送器
public interface SmsCodeSender { /** * 根据手机号发送短信验证码 * @param smsCode * @param phoneNumber */ void send(String smsCode, String phoneNumber); }
public class DefaultSmsCodeSender implements SmsCodeSender { @Override public void send(String smsCode, String phoneNumber) { System.out.printf("向手机号%s发送短信验证码%s", phoneNumber, smsCode); } }
验证码发送接口
@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)); } }
配置类
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
验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=600" alt="">
测试通过,重构成功!
短信验证码登录
未完待续……
参考资料
视频教程
链接: https://pan.baidu.com/s/1wQWD4wE0CSr9gSJ2JJsT_A 提取码: z6zi