Spring Security技术栈开发企业级认证与授权
环境准备
本文中所有实例代码已托管码云
文末有惊喜!
开发环境
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开发基于表单的认证
实现图形验证码功能
功能实现
由于图形验证码是通用功能,所以我们将相关逻辑写在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="">
测试通过,重构成功!
短信验证码登录
要想实现短信验证码登录流程,我们可以借鉴已有的用户名密码登录流程,分析有哪些组件是需要我们自己来实现的:
首先我们需要一个SmsAuthenticationFilter
拦截短信登录请求进行认证,期间它会将登录信息封装成一个Authentication
请求AuthenticationManager
进行认证
AuthenticationManager
会遍历所有的AuthenticationProvider
找到其中支持认证该Authentication
并调用authenticate
进行实际的认证,因此我们需要实现自己的Authentication
(SmsAuthenticationToken
)和认证该Authentication
的AuthenticationProvider
(SmsAuthenticationProvider
),并将SmsAuthenticationProvider
添加到SpringSecurty
的AuthenticationProvider
集合中,以使AuthenticationManager
遍历该集合时能找到我们自定义的SmsAuthenticationProvider
SmsAuthenticationProvider
在进行认证时,需要调用UserDetailsService
根据手机号查询存储的用户信息(loadUserByUsername
),因此我们还需要自定义的SmsUserDetailsService
下面我们来一一实现下(其实就是依葫芦画瓢,把对应用户名密码登录流程对应组件的代码COPY过来改一改)
SmsAuthenticationToken
package top.zhenganwen.security.core.verifycode.sms; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.SpringSecurityCoreVersion; import java.util.Collection; /** * @author zhenganwen * @date 2019/8/30 * @desc SmsAuthenticationToken */ public class SmsAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; // ~ Instance fields // ================================================================================================ // 认证前保存的是用户输入的手机号,认证成功后保存的是后端存储的用户详情 private final Object principal; // ~ Constructors // =================================================================================================== /** * 认证前时调用该方法封装请求参数成一个未认证的token => authRequest * * @param phoneNumber 手机号 */ public SmsAuthenticationToken(Object phoneNumber) { super(null); this.principal = phoneNumber; setAuthenticated(false); } /** * 认证成功后需要调用该方法封装用户信息成一个已认证的token => successToken * * @param principal 用户详情 * @param authorities 权限信息 */ public SmsAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; super.setAuthenticated(true); // must use super, as we override } // ~ Methods // ======================================================================================================== // 用户名密码登录的凭证是密码,验证码登录不传密码 @Override public Object getCredentials() { return null; } @Override public Object getPrincipal() { return this.principal; } }
SmsAuthenticationFilter
package top.zhenganwen.security.core.verifycode.sms; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.util.Assert; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * @author zhenganwen * @date 2019/8/30 * @desc SmsAuthenticationFilter */ public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter { // ~ Static fields/initializers // ===================================================================================== public static final String SPRING_SECURITY_FORM_PHONE_NUMBER_KEY = "phoneNumber"; private String phoneNumberParameter = SPRING_SECURITY_FORM_PHONE_NUMBER_KEY; private boolean postOnly = true; // ~ Constructors // =================================================================================================== public SmsAuthenticationFilter() { super(new AntPathRequestMatcher("/auth/sms", "POST")); } // ~ Methods // ======================================================================================================== @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException( "Authentication method not supported: " + request.getMethod()); } String phoneNumber = obtainPhoneNumber(request); if (phoneNumber == null) { phoneNumber = ""; } phoneNumber = phoneNumber.trim(); SmsAuthenticationToken authRequest = new SmsAuthenticationToken(phoneNumber); return this.getAuthenticationManager().authenticate(authRequest); } /** * Enables subclasses to override the composition of the phoneNumber, such as by * including additional values and a separator. * * @param request so that request attributes can be retrieved * * @return the phoneNumber that will be presented in the <code>Authentication</code> * request token to the <code>AuthenticationManager</code> */ protected String obtainPhoneNumber(HttpServletRequest request) { return request.getParameter(phoneNumberParameter); } /** * Sets the parameter name which will be used to obtain the phoneNumber from the login * request. * * @param phoneNumberParameter the parameter name. Defaults to "phoneNumber". */ public void setPhoneNumberParameter(String phoneNumberParameter) { Assert.hasText(phoneNumberParameter, "phoneNumber parameter must not be empty or null"); this.phoneNumberParameter = phoneNumberParameter; } /** * Defines whether only HTTP POST requests will be allowed by this filter. If set to * true, and an authentication request is received which is not a POST request, an * exception will be raised immediately and authentication will not be attempted. The * <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed * authentication. * <p> * Defaults to <tt>true</tt> but may be overridden by subclasses. */ public void setPostOnly(boolean postOnly) { this.postOnly = postOnly; } public final String getPhoneNumberParameter() { return phoneNumberParameter; } }
SmsAuthenticationProvider
package top.zhenganwen.security.core.verifycode.sms; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; /** * @author zhenganwen * @date 2019/8/30 * @desc SmsAuthenticationProvider */ public class SmsAuthenticationProvider implements AuthenticationProvider { private UserDetailsService userDetailsService; public SmsAuthenticationProvider() { } /** * 该方***被 AuthenticationManager调用,对authentication进行验证,并返回一个认证通过的{@link Authentication} * @param authentication * @return */ @Override public Authentication authenticate(Authentication authentication){ // 用户名密码登录方式需要在这里校验前端传入的密码和后端存储的密码是否一致 // 但如果将短信验证码的校验放在这里的话就无法复用了,例如用户登录后访问“我的钱包”服务可能也需要发送短信验证码并进行验证 // 因此短信验证码的校验逻辑单独抽取到一个过滤器里(留到后面实现), 这里直接返回一个认证成功的authentication if (authentication instanceof SmsAuthenticationToken == false) { throw new IllegalArgumentException("仅支持对SmsAuthenticationToken的认证"); } SmsAuthenticationToken authRequest = (SmsAuthenticationToken) authentication; UserDetails userDetails = getUserDetailsService().loadUserByUsername((String) authentication.getPrincipal()); SmsAuthenticationToken successfulAuthentication = new SmsAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities()); return successfulAuthentication; } /** * Authentication的authenticate方法在遍历所有AuthenticationProvider时会调用该方法判断当前AuthenticationProvider是否对 * 某个具体Authentication的校验 * * 重写此方法以支持对 {@link SmsAuthenticationToken} 的认证校验 * @param clazz 支持的token类型 * @return */ @Override public boolean supports(Class<?> clazz) { // 如果传入的类是否是SmsAuthenticationToken或其子类 return SmsAuthenticationToken.class.isAssignableFrom(clazz); } public UserDetailsService getUserDetailsService() { return userDetailsService; } /** * 提供对UserDetailsService的动态注入 * @return */ public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } }
SmsDetailsService
package top.zhenganwen.security.core.verifycode.sms; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Service; import java.util.Objects; /** * @author zhenganwen * @date 2019/8/30 * @desc SmsUserDetailsService */ @Service public class SmsUserDetailsService implements UserDetailsService { /** * 根据登录名查询用户,这里登录名是手机号 * * @param phoneNumber * @return * @throws PhoneNumberNotFoundException */ @Override public UserDetails loadUserByUsername(String phoneNumber) throws PhoneNumberNotFoundException { // 实际上应该调用DAO根据手机号查询用户 if (Objects.equals(phoneNumber, "12345678912") == false) { // 未查到 throw new PhoneNumberNotFoundException(); } // 查到了 // 使用security提供的UserDetails的实现模拟查出来的用户,在你的项目中可以使用User实体类实现UserDetails接口,这样就可以直接返回查出的User实体对象 return new User("anwen","123456", AuthorityUtils.createAuthorityList("admin","super_admin")); } }
这里要注意一下,添加了该类后,容器中就有两个UserDetails
组建了,之前@Autowire userDetails
的地方要换成@Autowire customDetailsService
,否则会报错
SmsLoginConfig
各个环节的组件我们都实现了,现在我们需要写一个配置类将这些组件串起来,告诉security
这些自定义组件的存在。由于短信登录方式在PC端和移动端都用得上,因此我们将其定义在security-core
中
package top.zhenganwen.security.core.verifycode.sms; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.SecurityConfigurerAdapter; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.stereotype.Component; /** * @author zhenganwen * @date 2019/8/30 * @desc SmsSecurityConfig */ @Component public class SmsLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Autowired AuthenticationSuccessHandler customAuthenticationSuccessHandler; @Autowired AuthenticationFailureHandler customAuthenticationFailureHandler; @Autowired UserDetailsService smsUserDetailsService; @Override public void configure(HttpSecurity http) throws Exception { SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter(); // 认证过滤器会请求AuthenticationManager认证authRequest,因此我们需要为其注入AuthenticatonManager,但是该实例是由Security管理的,我们需要通过getSharedObject来获取 smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); // 认证成功/失败处理器还是使用之前的 smsAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler); smsAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler); SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider(); // 将SmsUserDetailsService注入到SmsAuthenticationProvider中 smsAuthenticationProvider.setUserDetailsService(smsUserDetailsService); // 将SmsAuthenticationProvider加入到Security管理的AuthenticationProvider集合中 http.authenticationProvider(smsAuthenticationProvider) // 注意要添加到UsernamePasswordAuthenticationFilter之后,自定义的认证过滤器都应该添加到其之后,自定义的验证码等过滤器都应该添加到其之前 .addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }
测试
访问/login.html
,点击点击发送
,查看控制台输出的短信验证码,再访问/login.html
进行登录,登录成功!
但是,进行用户名密码登录却失败了!提示Bad Credentials
,说密码错误,于是我在校验密码的地方进行断点调试:
DaoAuthenticationProvider#additionalAuthenticationChecks
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { Object salt = null; if (this.saltSource != null) { salt = this.saltSource.getSalt(userDetails); } if (authentication.getCredentials() == null) { logger.debug("Authentication failed: no credentials provided"); throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } String presentedPassword = authentication.getCredentials().toString(); if (!passwordEncoder.isPasswordValid(userDetails.getPassword(), presentedPassword, salt)) { logger.debug("Authentication failed: password does not match stored value"); throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } }
发现passwordEncoder
居然是PlaintextPasswordEncoder
而不是我们注入的BCryptPasswordEncoder
,这是为什么呢?
我们需要追本溯源查看该passwordEncoder
是什么时候被赋值的,Alt + F7
在该文件中查看该类的setPasswordEncoder(Object passwordEncoder)
方法的调用时机,发现在构造方法中就会被初始化为PlaintextPasswordEncoder
;但这并不是我们想要的,我们想看为什么在添加短信验证码登录功能之前注入的加密器BCryptPasswordEncoder
就能生效,于是Ctrl + Alt + F7
在整个项目和类库中查找setPasswordEncoder(Object passwordEncoder)
的调用时机,发现如下线索:
InitializeUserDetailsManagerConfigurer
@Override public void configure(AuthenticationManagerBuilder auth) throws Exception { if (auth.isConfigured()) { return; } UserDetailsService userDetailsService = getBeanOrNull( UserDetailsService.class); if (userDetailsService == null) { return; } PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class); DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); if (passwordEncoder != null) { provider.setPasswordEncoder(passwordEncoder); } auth.authenticationProvider(provider); } /** * @return */ private <T> T getBeanOrNull(Class<T> type) { String[] userDetailsBeanNames = InitializeUserDetailsBeanManagerConfigurer.this.context .getBeanNamesForType(type); if (userDetailsBeanNames.length != 1) { return null; } return InitializeUserDetailsBeanManagerConfigurer.this.context .getBean(userDetailsBeanNames[0], type); }
原来,在查找我们是否注入其它PasswordEncoder
实例并试图向DaoAuthenticationProvider
注入我们配置的BCryptPasswordEncoder
之前,会从容器中获取UserDetails
实例,如果容器中没有或者实例个数大于1,那么就返回了。
原来,是我们在实现短信验证码登录功能时,在SmsUserDetailsService
标注的@Component
导致容器中存在了smsUserDetailsService
和之前的customUserDetailsService
两个UserDetailsService
实例,以至于上述代码12
之后的代码都未执行,也就是说我们的CustomUserDetailsService
和BCryptPasswordEncoder
都没有注入到DaoAuthenticationProvider
中去。
至于为什么校验密码之前,DaoAuthenticationProvider
中的this.getUserDetailsService().loadUserByUsername(username)
仍能调用CustomUserDetailsService
以及为什么是CustomUserDetailsService
被注入到了DaoAuthenticationProvider
中而不是SmsUserDetialsService
,还有待分析
既然找到了问题所在(容器中存在两个UserDetailsService
实例),简单的解决办法就是去掉SmsUserDetailsService
的@Component
,在配置短信登录串联组件时自己new
一个就好了
//@Component public class SmsUserDetailsService implements UserDetailsService {
@Component public class SmsLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Autowired AuthenticationSuccessHandler customAuthenticationSuccessHandler; @Autowired AuthenticationFailureHandler customAuthenticationFailureHandler; // @Autowired // SmsUserDetailsService smsUserDetailsService; @Override public void configure(HttpSecurity http) throws Exception { SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter(); smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); smsAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler); smsAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler); SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider(); // 自己new一下 SmsUserDetailsService smsUserDetailsService = new SmsUserDetailsService(); smsAuthenticationProvider.setUserDetailsService(smsUserDetailsService); http.authenticationProvider(smsAuthenticationProvider) .addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }
重新测试两种登录方式,均能通过!
短信验证码过滤器
上节说道,为了复用,我们应该将短信验证码的验证逻辑单独放到一个过滤器中,这里我们可以参考之前写的图形验证码过滤器,复制一份改一改
package top.zhenganwen.security.core.verifycode.filter; import org.apache.commons.lang.StringUtils; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.social.connect.web.HttpSessionSessionStrategy; import org.springframework.social.connect.web.SessionStrategy; import org.springframework.stereotype.Component; import org.springframework.util.AntPathMatcher; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.filter.OncePerRequestFilter; import top.zhenganwen.security.core.properties.SecurityProperties; import top.zhenganwen.security.core.verifycode.exception.VerifyCodeException; import top.zhenganwen.security.core.verifycode.po.VerifyCode; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Arrays; import java.util.HashSet; import java.util.Set; import static top.zhenganwen.security.core.verifycode.constont.VerifyCodeConstant.SMS_CODE_SESSION_KEY; /** * @author zhenganwen * @date 2019/8/24 * @desc VerifyCodeAuthenticationFilter */ @Component public class SmsCodeAuthenticationFilter extends OncePerRequestFilter implements InitializingBean { @Autowired private AuthenticationFailureHandler customAuthenticationFailureHandler; private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy(); @Autowired private SecurityProperties securityProperties; private Set<String> uriPatternSet = new HashSet<>(); // uri匹配工具类,帮我们做类似/user/1到/user/*的匹配 private AntPathMatcher antPathMatcher = new AntPathMatcher(); @Override public void afterPropertiesSet() throws ServletException { super.afterPropertiesSet(); String uriPatterns = securityProperties.getCode().getSms().getUriPatterns(); if (StringUtils.isNotBlank(uriPatterns)) { String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(uriPatterns, ","); uriPatternSet.addAll(Arrays.asList(strings)); } uriPatternSet.add("/auth/sms"); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { for (String uriPattern : uriPatternSet) { // 有一个匹配就需要拦截 校验验证码 if (antPathMatcher.match(uriPattern, request.getRequestURI())) { try { this.validateVerifyCode(new ServletWebRequest(request)); } catch (VerifyCodeException e) { customAuthenticationFailureHandler.onAuthenticationFailure(request, response, e); return; } break; } } filterChain.doFilter(request, response); } // 拦截用户登录的请求,从Session中读取保存的短信验证码和用户提交的验证码进行比对 private void validateVerifyCode(ServletWebRequest request){ String smsCode = (String) request.getParameter("smsCode"); if (StringUtils.isBlank(smsCode)) { throw new VerifyCodeException("验证码不能为空"); } VerifyCode verifyCode = (VerifyCode) sessionStrategy.getAttribute(request, SMS_CODE_SESSION_KEY); if (verifyCode == null) { throw new VerifyCodeException("验证码不存在"); } if (verifyCode.isExpired()) { throw new VerifyCodeException("验证码已过期,请刷新页面"); } if (StringUtils.equals(smsCode,verifyCode.getCode()) == false) { throw new VerifyCodeException("验证码错误"); } sessionStrategy.removeAttribute(request, SMS_CODE_SESSION_KEY); } }
然后记得将其添加到security
的过滤器链中,并且只能添加到所有认证过滤器之前:
SecurityBrowserConfig
@Override protected void configure(HttpSecurity http) throws Exception { http .addFilterBefore(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class) .addFilterBefore(verifyCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .formLogin() .loginPage("/auth/require") .loginProcessingUrl("/auth/login") .successHandler(customAuthenticationSuccessHandler) .failureHandler(customAuthenticationFailureHandler) .and() .rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(3600) .userDetailsService(customUserDetailsService) // 可配置页面选框的name属性 // .rememberMeParameter() .and() .authorizeRequests() .antMatchers( "/auth/require", securityProperties.getBrowser().getLoginPage(), "/verifyCode/**").permitAll() .anyRequest().authenticated() .and() .csrf().disable() .apply(smsLoginConfig); }
最后在login.html
中修改登录URL/auth/sms
以及短信验证码参数名smsCode
:
<form action="/auth/login" method="post"> 用户名: <input type="text" name="username" value="admin"> 密码: <input type="password" name="password" value="123"> 验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=600" alt=""> <input type="checkbox" name="remember-me" value="true">记住我 <button type="submit">提交</button> </form> <hr/> <form action="/auth/sms" method="post"> 手机号: <input type="text" name="phoneNumber" value="12345678912"> 验证码: <input type="text" name="smsCode"><a href="/verifyCode/sms?phoneNumber=12345678912">点击发送</a> <input type="checkbox" name="remember-me" value="true">记住我 <button type="submit">提交</button> </form>
重构——消除重复代码
之前我们将图形验证码过滤器的代码COPY一份改了改就成了短信验证码过滤器,这两个类的主流程是相同的,只是具体实现稍有不同(从Session中读写不同的key对应的验证码对象),这可以使用模板方法进行抽取
我们代码中还存在很多字面量魔法值,我们也应该尽量消除他们,将它们提取成常量或配置属性,在需要用到的地方统一进行引用,这样就不会导致后续需要更改时忘记了某处的魔法值而导致异常。例如,如果仅仅将.loginPage("/auth/require")
改为.loginPage("/authentication/require")
,而没有通过更改BrowserSecurityController
中的@RequestMapping("/auth/require")
,就会导致程序功能出现问题
我们可以将系统配置相关的代码分模块封装成对应的配置类放在security-core
中,security-browser
和security-app
中只留自身特有的配置(例如将token
写到cookie
中的remember-me
方式应该放在security-browser
中,而security-app
中对应放移动端remember-me
的配置方式),最后security-browser
和security-app
都可以通过http.apply
的方式引用security-core
中的通用配置,以实现代码的复用
只要你的项目中出现了两处以上相同的代码,你敏锐的嗅觉就应该发现这些最不起眼但也是最需要注意的代码坏味道,应该想办法及时重构而不要等到系统庞大后想动却牵一发而动全身
魔法值重构
package top.zhenganwen.security.core.verifycode.filter; public enum VerifyCodeType { SMS{ @Override public String getVerifyCodeParameterName() { return SecurityConstants.DEFAULT_SMS_CODE_PARAMETER_NAME; } }, IMAGE{ @Override public String getVerifyCodeParameterName() { return SecurityConstants.DEFAULT_IMAGE_CODE_PARAMETER_NAME; } }; public abstract String getVerifyCodeParameterName(); }
package top.zhenganwen.security.core; public interface SecurityConstants { /** * 表单密码登录URL */ String DEFAULT_FORM_LOGIN_URL = "/auth/login"; /** * 短信登录URL */ String DEFAULT_SMS_LOGIN_URL = "/auth/sms"; /** * 前端图形验证码参数名 */ String DEFAULT_IMAGE_CODE_PARAMETER_NAME = "imageCode"; /** * 前端短信验证码参数名 */ String DEFAULT_SMS_CODE_PARAMETER_NAME = "smsCode"; /** * 图形验证码缓存在Session中的key */ String IMAGE_CODE_SESSION_KEY = "IMAGE_CODE_SESSION_KEY"; /** * 短信验证码缓存在Session中的key */ String SMS_CODE_SESSION_KEY = "SMS_CODE_SESSION_KEY"; /** * 验证码校验器bean名称的后缀 */ String VERIFY_CODE_VALIDATOR_NAME_SUFFIX = "CodeValidator"; /** * 未登录访问受保护URL则跳转路径到 此 */ String FORWARD_TO_LOGIN_PAGE_URL = "/auth/require"; /** * 用户点击发送验证码调用的服务 */ String VERIFY_CODE_SEND_URL = "/verifyCode/**"; }
验证码过滤器重构
VerifyCodeValidatorFilter
,责任是拦截需要进行验证码校验的请求VerifyCodeValidator
,使用模板方法,抽象验证码的校验逻辑VerifyCodeValidatorHolder
,利用Spring的依赖查找,聚集容器中所有的VerifyCodeValidator
实现类(各种验证码的具体验证逻辑),对外提供根据验证码类型获取对应验证码校验bean
的方法
login.html
,将其中图形验证码参数改成了imageCode
<form action="/auth/login" method="post"> 用户名: <input type="text" name="username" value="admin"> 密码: <input type="password" name="password" value="123"> 验证码:<input type="text" name="imageCode"><img src="/verifyCode/image?width=600" alt=""> <input type="checkbox" name="remember-me" value="true">记住我 <button type="submit">提交</button> </form> <hr/> <form action="/auth/sms" method="post"> 手机号: <input type="text" name="phoneNumber" value="12345678912"> 验证码: <input type="text" name="smsCode"><a href="/verifyCode/sms?phoneNumber=12345678912">点击发送</a> <input type="checkbox" name="remember-me" value="true">记住我 <button type="submit">提交</button> </form>
VerifyCodeValidateFilter
:
package top.zhenganwen.security.core.verifycode.filter; import static top.zhenganwen.security.core.SecurityConstants.DEFAULT_SMS_LOGIN_URL; @Component public class VerifyCodeValidateFilter extends OncePerRequestFilter implements InitializingBean { // 认证失败处理器 @Autowired private AuthenticationFailureHandler authenticationFailureHandler; // session读写工具 private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy(); // 映射 需要校验验证码的 uri 和 校验码类型,如 /auth/login -> 图形验证码 /auth/sms -> 短信验证码 private Map<String, VerifyCodeType> uriMap = new HashMap<>(); @Autowired private SecurityProperties securityProperties; private AntPathMatcher antPathMatcher = new AntPathMatcher(); @Autowired private VerifyCodeValidatorHolder verifyCodeValidatorHolder; @Override public void afterPropertiesSet() throws ServletException { super.afterPropertiesSet(); uriMap.put(SecurityConstants.DEFAULT_FORM_LOGIN_URL, VerifyCodeType.IMAGE); putUriPatterns(uriMap, securityProperties.getCode().getImage().getUriPatterns(), VerifyCodeType.IMAGE); uriMap.put(SecurityConstants.DEFAULT_SMS_LOGIN_URL, VerifyCodeType.SMS); putUriPatterns(uriMap, securityProperties.getCode().getSms().getUriPatterns(), VerifyCodeType.SMS); } private void putUriPatterns(Map<String, VerifyCodeType> urlMap, String uriPatterns, VerifyCodeType verifyCodeType) { if (StringUtils.isNotBlank(uriPatterns)) { String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(uriPatterns, ","); for (String string : strings) { urlMap.put(string, verifyCodeType); } } } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException , IOException { try { checkVerifyCodeIfNeed(request, uriMap); } catch (VerifyCodeException e) { authenticationFailureHandler.onAuthenticationFailure(request, response, e); return; } filterChain.doFilter(request, response); } private void checkVerifyCodeIfNeed(HttpServletRequest request, Map<String, VerifyCodeType> uriMap) { String requestUri = request.getRequestURI(); Set<String> uriPatterns = uriMap.keySet(); for (String uriPattern : uriPatterns) { if (antPathMatcher.match(uriPattern, requestUri)) { VerifyCodeType verifyCodeType = uriMap.get(uriPattern); verifyCodeValidatorHolder.getVerifyCodeValidator(verifyCodeType).validateVerifyCode(new ServletWebRequest(request), verifyCodeType); break; } } } }
VerifyCodeValidator
package top.zhenganwen.security.core.verifycode.filter; import java.util.Objects; public abstract class VerifyCodeValidator { protected SessionStrategy sessionStrategy = new HttpSessionSessionStrategy(); @Autowired private VerifyCodeValidatorHolder verifyCodeValidatorHolder; /** * 校验验证码 * 1.从请求中获取传入的验证码 * 2.从服务端获取存储的验证码 * 3.校验验证码 * 4.校验成功移除服务端验证码,校验失败抛出异常信息 * * @param request * @param verifyCodeType * @throws VerifyCodeException */ public void validateVerifyCode(ServletWebRequest request, VerifyCodeType verifyCodeType) throws VerifyCodeException { String requestCode = getVerifyCodeFromRequest(request, verifyCodeType); VerifyCodeValidator codeValidator = verifyCodeValidatorHolder.getVerifyCodeValidator(verifyCodeType); if (Objects.isNull(codeValidator)) { throw new VerifyCodeException("不支持的验证码校验类型: " + verifyCodeType); } VerifyCode storedVerifyCode = codeValidator.getStoredVerifyCode(request); codeValidator.validate(requestCode, storedVerifyCode); codeValidator.removeStoredVerifyCode(request); } /** * 校验验证码是否过期,默认进行简单的文本比对,子类可重写以校验传入的明文验证码和后端存储的密文验证码 * * @param requestCode * @param storedVerifyCode */ private void validate(String requestCode, VerifyCode storedVerifyCode) { if (Objects.isNull(storedVerifyCode) || storedVerifyCode.isExpired()) { throw new VerifyCodeException("验证码已失效,请重新生成"); } if (StringUtils.isBlank(requestCode)) { throw new VerifyCodeException("验证码不能为空"); } if (StringUtils.equalsIgnoreCase(requestCode, storedVerifyCode.getCode()) == false) { throw new VerifyCodeException("验证码错误"); } } /** * 是从Session中还是从其他缓存方式移除验证码由子类自己决定 * * @param request */ protected abstract void removeStoredVerifyCode(ServletWebRequest request); /** * 是从Session中还是从其他缓存方式读取验证码由子类自己决定 * * @param request * @return */ protected abstract VerifyCode getStoredVerifyCode(ServletWebRequest request); /** * 默认从请求中获取验证码参数,可被子类重写 * * @param request * @param verifyCodeType * @return */ private String getVerifyCodeFromRequest(ServletWebRequest request, VerifyCodeType verifyCodeType) { try { return ServletRequestUtils.getStringParameter(request.getRequest(), verifyCodeType.getVerifyCodeParameterName()); } catch (ServletRequestBindingException e) { throw new VerifyCodeException("非法请求,请附带验证码参数"); } } }
ImageCodeValidator
package top.zhenganwen.security.core.verifycode.filter; @Component public class ImageCodeValidator extends VerifyCodeValidator { @Override protected void removeStoredVerifyCode(ServletWebRequest request) { sessionStrategy.removeAttribute(request, SecurityConstants.IMAGE_CODE_SESSION_KEY); } @Override protected VerifyCode getStoredVerifyCode(ServletWebRequest request) { return (VerifyCode) sessionStrategy.getAttribute(request, SecurityConstants.IMAGE_CODE_SESSION_KEY); } }
SmsCodeValidator
package top.zhenganwen.security.core.verifycode.filter; @Component public class SmsCodeValidator extends VerifyCodeValidator { @Override protected void removeStoredVerifyCode(ServletWebRequest request) { sessionStrategy.removeAttribute(request, SecurityConstants.SMS_CODE_SESSION_KEY); } @Override protected VerifyCode getStoredVerifyCode(ServletWebRequest request) { return (VerifyCode) sessionStrategy.getAttribute(request,SecurityConstants.SMS_CODE_SESSION_KEY); } }
VerifyCodeValidatorHolder
package top.zhenganwen.security.core.verifycode.filter; @Component public class VerifyCodeValidatorHolder { @Autowired private Map<String, VerifyCodeValidator> verifyCodeValidatorMap = new HashMap<>(); public VerifyCodeValidator getVerifyCodeValidator(VerifyCodeType verifyCodeType) { VerifyCodeValidator verifyCodeValidator = verifyCodeValidatorMap.get(verifyCodeType.toString().toLowerCase() + SecurityConstants.VERIFY_CODE_VALIDATOR_NAME_SUFFIX); if (Objects.isNull(verifyCodeType)) { throw new VerifyCodeException("不支持的验证码类型:" + verifyCodeType); } return verifyCodeValidator; } }
SecurityBrowserConfig
@Autowire VerifyCodeValidatorFilter verifyCodeValidatorFilter; http // .addFilterBefore(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class) // .addFilterBefore(verifyCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(verifyCodeValidateFilter, UsernamePasswordAuthenticationFilter.class) .formLogin() .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL) .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL) .successHandler(customAuthenticationSuccessHandler) .failureHandler(customAuthenticationFailureHandler) .and() .rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(3600) .userDetailsService(customUserDetailsService) .and() .authorizeRequests() .antMatchers( SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL, securityProperties.getBrowser().getLoginPage(), SecurityConstants.VERIFY_CODE_SEND_URL).permitAll() .anyRequest().authenticated() .and() .csrf().disable() .apply(smsLoginConfig);
系统配置重构
security-core
package top.zhenganwen.security.core.config; @Component public class SmsLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Autowired AuthenticationSuccessHandler customAuthenticationSuccessHandler; @Autowired AuthenticationFailureHandler customAuthenticationFailureHandler; @Override public void configure(HttpSecurity http) throws Exception { SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter(); smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); smsAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler); smsAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler); SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider(); SmsUserDetailsService smsUserDetailsService = new SmsUserDetailsService(); smsAuthenticationProvider.setUserDetailsService(smsUserDetailsService); http.authenticationProvider(smsAuthenticationProvider) .addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }
package top.zhenganwen.security.core.config; @Component public class VerifyCodeValidatorConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Autowired private VerifyCodeValidateFilter verifyCodeValidateFilter; @Override public void configure(HttpSecurity builder) throws Exception { builder.addFilterBefore(verifyCodeValidateFilter, UsernamePasswordAuthenticationFilter.class); } }
security-browser
package top.zhenganwen.securitydemo.browser; @Configuration public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter { @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Autowired private SecurityProperties securityProperties; @Autowired private AuthenticationSuccessHandler customAuthenticationSuccessHandler; @Autowired private AuthenticationFailureHandler customAuthenticationFailureHandler; @Autowired private DataSource dataSource; @Autowired private UserDetailsService customUserDetailsService; @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); return jdbcTokenRepository; } @Autowired SmsLoginConfig smsLoginConfig; @Autowired private VerifyCodeValidatorConfig verifyCodeValidatorConfig; @Override protected void configure(HttpSecurity http) throws Exception { // 启用验证码校验过滤器 http.apply(verifyCodeValidatorConfig); // 启用短信登录过滤器 http.apply(smsLoginConfig); http // 启用表单密码登录过滤器 .formLogin() .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL) .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL) .successHandler(customAuthenticationSuccessHandler) .failureHandler(customAuthenticationFailureHandler) .and() // 浏览器应用特有的配置,将登录后生成的token保存在cookie中 .rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(3600) .userDetailsService(customUserDetailsService) .and() // 浏览器应用特有的配置 .authorizeRequests() .antMatchers( SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL, securityProperties.getBrowser().getLoginPage(), SecurityConstants.VERIFY_CODE_SEND_URL).permitAll() .anyRequest().authenticated().and() .csrf().disable(); } }
使用Spring Social开发社交登录
OAuth协议简介
产生背景
有时应用与应用之间会进行合作,已达到共赢的目的。例如时下较火的微信公众号、微信小程序。一方面,公众号、小程序开发者能够以丰富的内容吸引微信用户为微信提高用户留存率;另一方面,公众号、小程序能够借助微信强大的用户基础为自己的服务引流
这时问题来了,如果使用最传统的方式,小程序要想取得用户信息而向用户申请索取账号密码(例如美颜小程序需要读取用户的微信相册进行美化),且不说用户给不给,就算用户给了,那么还是会存在以下几个问题(以美颜小程序为例)
访问权限
无法控制小程序的访问权限,说是只读取微信相册,谁知道他拿了账号密码后会不会查看微信好友、使用微信钱包呢
授权时效
一旦小程序获取到用户的账号密码,用户便无法控制此次授权后,该小程序日后还不会使用该账号密码进行非法登录,用户只有每次授权后更改密码才行
可靠性
如果用户采用此种方式对多个小程序进行授权,一旦小程序泄露用户密码,那么用户面临被盗号的危险
OAuth解决方案
用户同意授权给第三方应用(如微信小程序相对于微信用户)时,只会给第三方应用一个token
令牌(第三方应用可以通过这个token
访问用户的特定数据资源),这个令牌就是为了解决上述问题而生:
- 令牌是有时限的,只在规定的时间内有效,解决了 授权时效 的问题
- 令牌只能访问用户授予访问的特定资源,解决了 访问权限 的问题
- 令牌是一串短期有效,过期则没有任何意义的随机字符串 ,解决了 可靠性 问题
OAuth协议运行流程
首先介绍一下涉及到的几个角色及其职责:
Provider
,服务提供商,如微信、QQ,拥有大量的用户数据Authorization Server
,认证服务器,用户同意授权后,由认证服务器来生成token
传给第三方应用Resource Server
,存储了第三方应用所需的资源,确认token
无误则开放相应资源给第三方应用
Resource Owner
,资源所有者,如微信用户就是微信相册的资源所有者,相片是微信用户拍的,只不过存储在了微信服务器上Client
,第三方应用,需要依赖具有强大用户基础的服务提供商进行引流的应用
上述第二步还涉及到几种授权模式:
- 授权码模式(
authorization code
) - 密码模式(
resource owner password credentials
) - 客户卡模式(
client credentials
) - 简化模式(
implicit
)
本章和下一章(app)将分别详细介绍前两种模式,现在互联网上几乎大部分社交平台如QQ、微博、淘宝等服务提供商都是采用的授权码模式
授权码模式授权流程
以我们平常访问某社交网站时不想注册该网站用户而直接使用QQ登录这一场景为例,如图是该社交网站作为第三方应用使用OAuth协议开发QQ联合登录的大致时序图
授权码模式之所以被广泛使用,其原因有如下两点:
- 用户同意授权这一行为是在认证服务器上进行确认的,相比较其他3种模式在第三方应用客户端上确认(客户端可伪造用户同意授权)而言,更加透明
- 认证服务器不是直接返回token,而是先返回授权码。像有的静态网站可能会使用
implicit
模式让认证服务器直接返回token从而再在页面上使用AJAX调用资源服务器接口。前者是认证服务器对接第三方应用服务器(认证服务器返回token
是通过回调与第三方应用事先约定好的第三方应用接口并传入token
,因此所有token
都是存放在服务端的);而后者是认证服务器对接浏览器等第三方应用的客户端,token
直接传给客户端存在安全风险
这也是为什么现在主流的服务提供商都采用授权码模式,因为其授权流程更完备、更安全。
Spring Social基本原理
Spring Social其实就是将上述时序图所描述的授权流程封装到了特定的类和接口中了。OAuth协议有两个版本,国外很早就用了所以流行OAuth1,而国内用得比较晚因此基本都是OAuth2,本章也是基于OAuth2来集成QQ、微信登录功能。
如图是Spring Social的主要组件,各功能如下:
OAuth2Operations
,封装从请求用户授权到认证服务向我们返回token
的整个流程。OAuth2Template
是为我们提供的默认实现,这个流程基本上是固定的,无需我们介入- Api,封装拿到
token
后我们调用资源服务器接口获取用户信息的过程,这个需要我们自己定义,毕竟框架也不知道我们要接入哪个开放平台,但它也为我们提供了一个抽象AbstractOAuth2ApiBinding
AbstractOAuth2ServiceProvider
,集成OAuth2Operation
和Api
,串起获取token
和拿token
访问用户资源两个过程Connection
,统一用户视图,由于各服务提供商返回的用户信息数据结构是不一致的,我们需要通过适配器ApiAdapter
将其统一适配到Connection
这个数据结构上,可以看做用户在服务提供商中的实体OAuth2ConnectionFactory
,集成AbstractOAuth2ServiceProvider
和ApiAdapter
,完成整个用户授权以及获取用户信息实体的流程UsersConnectionRepository
,我们的系统中一般都有自己的用户表,如何将接入系统的用户实体Connection
和我们自己的用户实体User
进行对应就靠它来完成,用来完成我们userId
到Connection
的映射
开发QQ登录功能
准备工作:申请appId和appSecret,详见准备工作_oauth2-0
要开发一个第三方接入功能其实就是对上图一套组件逐个进行实现一下,本节我们将开发QQ登录功能,首先从上图的左半部分开始实现。
ServiceProvider
Api
,声明一个对应OpenAPI的方法,用来调用该API并将响应结果转成POJO返回,对应授权码模式时序图中的第7步
package top.zhenganwen.security.core.social.qq.api; import top.zhenganwen.security.core.social.qq.QQUserInfo; /** * @author zhenganwen * @date 2019/9/4 * @desc QQApi 封装对QQ开放平台接口的调用 */ public interface QQApi { QQUserInfo getUserInfo(); }
package top.zhenganwen.security.core.social.qq.api; import lombok.Data; import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.social.oauth2.AbstractOAuth2ApiBinding; import org.springframework.social.oauth2.TokenStrategy; import top.zhenganwen.security.core.social.qq.QQUserInfo; /** * @author zhenganwen * @date 2019/9/3 * @desc QQApiImpl 拿token调用开放接口获取用户信息 * 1.首先要根据 https://graph.qq.com/oauth2.0/me/{token} 获取用户在社交平台上的id => {@code openId} * 2.调用QQ OpenAPI https://graph.qq.com/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID * 获取用户在社交平台上的信息 => {@link QQApiImpl#getUserInfo()} * <p> * {@link AbstractOAuth2ApiBinding} * 帮我们完成了调用OpenAPI时附带{@code token}参数, 见其成员变量{@code accessToken} * 帮我们完成了HTTP调用, 见其成员变量{@code restTemplate} * <p> * 注意:该组件应是多例的,因为每个用户对应有不同的OpenAPI,每次不同的用户进行QQ联合登录都应该创建一个新的 {@link QQApiImpl} */ @Data public class QQApiImpl extends AbstractOAuth2ApiBinding implements QQApi { private static final String URL_TO_GET_OPEN_ID = "https://graph.qq.com/oauth2.0/me?access_token=%s"; // 因为父类会帮我们附带token参数,因此这里URL忽略了token参数 private static final String URL_TO_GET_USER_INFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s"; private String openId; private String appId; private Logger logger = LoggerFactory.getLogger(getClass()); public QQApiImpl(String accessToken,String appId) { // 调用OpenAPI时将需要传递的参数附在URL路径上 super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER); this.appId = appId; // 获取用户openId, 响应结果格式:callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} ); String responseForGetOpenId = getRestTemplate().getForObject(String.format(URL_TO_GET_OPEN_ID, accessToken), String.class); logger.info("获取用户对应的openId:{}", responseForGetOpenId); this.openId = StringUtils.substringBetween(responseForGetOpenId, "\"openid\":\"", "\"}"); } @Override public QQUserInfo getUserInfo() { QQUserInfo qqUserInfo = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), QQUserInfo.class); logger.info("调用QQ OpenAPI获取用户信息: {}", qqUserInfo); return qqUserInfo; } }
然后是OAuth2Operations
,用来封装将用户导入授权页面、获取用户授权后传入的授权码、获取访问OpenAPI的token,对应授权码模式时序图中的第2~6步。由于这几步模式是固定的,所以Spring Social
帮我们做了强封装,即OAuth2Template
,因此无需我们自己实现,后面直接使用该组件即可
ServiceProvider
,集成OAuth2Operations
和Api
,使用前者来完成授权获取token,使用后者携带token调用OpenAPI获取用户信息
package top.zhenganwen.security.core.social.qq.connect; import org.springframework.social.oauth2.AbstractOAuth2ServiceProvider; import org.springframework.social.oauth2.OAuth2Operations; import top.zhenganwen.security.core.social.qq.api.QQApiImpl; /** * @author zhenganwen * @date 2019/9/4 * @desc QQServiceProvider 对接服务提供商,封装一整套授权登录流程, 从用户点击第三方登录按钮到掉第三方应用OpenAPI获取Connection(用户信息) * 委托 {@link OAuth2Operations} 和 {@link org.springframework.social.oauth2.AbstractOAuth2ApiBinding}来完成整个流程 */ public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQApiImpl> { /** * 当前应用在服务提供商注册的应用id */ private String appId; /** * @param oauth2Operations 封装逻辑: 跳转到认证服务器、用户授权、获取授权码、获取token * @param appId 当前应用的appId */ public QQServiceProvider(OAuth2Operations oauth2Operations, String appId) { super(oauth2Operations); this.appId = appId; } @Override public QQApiImpl getApi(String accessToken) { return new QQApiImpl(accessToken,appId); } }
ConnectionFactory
UserInfo
,封装OpenAPI返回的用户信息
package top.zhenganwen.security.core.social.qq; import lombok.Data; import java.io.Serializable; /** * @author zhenganwen * @date 2019/9/4 * @desc QQUserInfo 用户在QQ应用注册的信息 */ @Data public class QQUserInfo implements Serializable { /** * 返回码 */ private String ret; /** * 如果ret<0,会有相应的错误信息提示,返回数据全部用UTF-8编码。 */ private String msg; /** * */ private String openId; /** * 不知道什么东西,文档上没写,但是实际api返回里有。 */ private String is_lost; /** * 省(直辖市) */ private String province; /** * 市(直辖市区) */ private String city; /** * 出生年月 */ private String year; /** * 用户在QQ空间的昵称。 */ private String nickname; /** * 大小为30×30像素的QQ空间头像URL。 */ private String figureurl; /** * 大小为50×50像素的QQ空间头像URL。 */ private String figureurl_1; /** * 大小为100×100像素的QQ空间头像URL。 */ private String figureurl_2; /** * 大小为40×40像素的QQ头像URL。 */ private String figureurl_qq_1; /** * 大小为100×100像素的QQ头像URL。需要注意,不是所有的用户都拥有QQ的100×100的头像,但40×40像素则是一定会有。 */ private String figureurl_qq_2; /** * 性别。 如果获取不到则默认返回”男” */ private String gender; /** * 标识用户是否为黄钻用户(0:不是;1:是)。 */ private String is_yellow_vip; /** * 标识用户是否为黄钻用户(0:不是;1:是) */ private String vip; /** * 黄钻等级 */ private String yellow_vip_level; /** * 黄钻等级 */ private String level; /** * 标识是否为年费黄钻用户(0:不是; 1:是) */ private String is_yellow_year_vip; }
ApiAdapter
,将不同的第三方应用返回的不同用户信息数据格式转换成统一的用户视图
package top.zhenganwen.security.core.social.qq.connect; import org.springframework.social.connect.ApiAdapter; import org.springframework.social.connect.ConnectionValues; import org.springframework.social.connect.UserProfile; import org.springframework.stereotype.Component; import top.zhenganwen.security.core.social.qq.QQUserInfo; import top.zhenganwen.security.core.social.qq.api.QQApiImpl; /** * @author zhenganwen * @date 2019/9/4 * @desc QQConnectionAdapter 从不同第三方应用返回的不同用户信息到统一用户视图{@link org.springframework.social.connect.Connection}的适配 */ @Component public class QQConnectionAdapter implements ApiAdapter<QQApiImpl> { // 测试OpenAPI接口是否可用 @Override public boolean test(QQApiImpl api) { return true; } /** * 调用OpenAPI获取用户信息并适配成{@link org.springframework.social.connect.Connection} * 注意: 不是所有的社交应用都对应有{@link org.springframework.social.connect.Connection}中的属性,例如QQ就不像微博那样有个人主页 * @param api * @param values */ @Override public void setConnectionValues(QQApiImpl api, ConnectionValues values) { QQUserInfo userInfo = api.getUserInfo(); // 用户昵称 values.setDisplayName(userInfo.getNickname()); // 用户头像 values.setImageUrl(userInfo.getFigureurl_2()); // 用户个人主页 values.setProfileUrl(null); // 用户在社交平台上的id values.setProviderUserId(userInfo.getOpenId()); } // 此方法作用和 setConnectionValues 类似,在后续开发社交账号绑定、解绑时再说 @Override public UserProfile fetchUserProfile(QQApiImpl api) { return null; } /** * 调用OpenAPI更新用户动态 * 由于QQ OpenAPI没有此功能,因此不用管(如果接入微博则可能需要重写此方法) * @param api * @param message */ @Override public void updateStatus(QQApiImpl api, String message) { } }
ConnectionFactory
package top.zhenganwen.security.core.social.qq.connect; import org.springframework.social.connect.ApiAdapter; import org.springframework.social.connect.support.OAuth2ConnectionFactory; import org.springframework.social.oauth2.OAuth2ServiceProvider; import top.zhenganwen.security.core.social.qq.api.QQApiImpl; public class QQConnectionFactory extends OAuth2ConnectionFactory<QQApiImpl> { public QQConnectionFactory(String providerId,OAuth2ServiceProvider<QQApiImpl> serviceProvider, ApiAdapter<QQApiImpl> apiAdapter) { super(providerId, serviceProvider, apiAdapter); } }
createConnectionFactory
我们需要重写SocialAutoConfigurerAdapter
中的createConnectionFactory
方法注入我们自定义的ConnectionFacory
,SpringSoical将使用它来完成授权码模式的第2~7步
package top.zhenganwen.security.core.social.qq.connect; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.social.SocialAutoConfigurerAdapter; import org.springframework.context.annotation.Bean; import org.springframework.social.connect.ConnectionFactory; import org.springframework.social.oauth2.OAuth2Operations; import org.springframework.social.oauth2.OAuth2Template; import org.springframework.stereotype.Component; import top.zhenganwen.security.core.properties.SecurityProperties; @Component @ConditionalOnProperty(prefix = "demo.security.qq",name = "appId") public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter { public static final String URL_TO_GET_AUTHORIZATION_CODE = "https://graph.qq.com/oauth2.0/authorize"; public static final String URL_TO_GET_TOKEN = "https://graph.qq.com/oauth2.0/token"; @Autowired private SecurityProperties securityProperties; @Autowired private QQConnectionAdapter qqConnectionAdapter; @Override protected ConnectionFactory<?> createConnectionFactory() { return new QQConnectionFactory( securityProperties.getQq().getProviderId(), new QQServiceProvider(oAuth2Operations(), securityProperties.getQq().getAppId()), qqConnectionAdapter); } @Bean public OAuth2Operations oAuth2Operations() { return new OAuth2Template( securityProperties.getQq().getAppId(), securityProperties.getQq().getAppSecret(), URL_TO_GET_AUTHORIZATION_CODE, URL_TO_GET_TOKEN); } }
QQSecurityProperties
,QQ登录相关配置项
package top.zhenganwen.security.core.social.qq.connect; import lombok.Data; @Data public class QQSecurityPropertie { private String appId; private String appSecret; private String providerId = "qq"; }
package top.zhenganwen.security.core.properties; @Data @ConfigurationProperties(prefix = "demo.security") public class SecurityProperties { private BrowserProperties browser = new BrowserProperties(); private VerifyCodeProperties code = new VerifyCodeProperties(); private QQSecurityPropertie qq = new QQSecurityPropertie(); }
UsersConnectionRepository
我们需要一张表来维护当前系统用户表与用户在第三方应用注册的信息之间的对应关系,SpringSocial为我们提供了该表(在JdbcUsersConnectionRepository.java
文件同一目录下)
CREATE TABLE UserConnection ( userId VARCHAR (255) NOT NULL, providerId VARCHAR (255) NOT NULL, providerUserId VARCHAR (255), rank INT NOT NULL, displayName VARCHAR (255), profileUrl VARCHAR (512), imageUrl VARCHAR (512), accessToken VARCHAR (512) NOT NULL, secret VARCHAR (512), refreshToken VARCHAR (512), expireTime BIGINT, PRIMARY KEY ( userId, providerId, providerUserId ) ); CREATE UNIQUE INDEX UserConnectionRank ON UserConnection (userId, providerId, rank);
其中userId
为当前系统用户的唯一标识(不一定是用户表主键,也可以是用户名,只要是用户表中能唯一标识用户的字段就行),providerId
用来标识第三方应用,providerUserId
是用户在该第三方应用中的用户标识。这三个字段能够标识第三方应用(providerId)用户(providerUserId)在当前系统中对应的用户(userId)。我们将此SQL在Datasource对应的数据库中执行以下。
SpringSocial为我们提供了JdbcUsersConnectionRepository
作为该张表的DAO,我们需要将当前系统的数据源注入给它,并继承SocialConfigurerAdapter
和添加@EnableSocial
来启用SpringSocial的一些自动化配置
package top.zhenganwen.security.core.social.qq; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.encrypt.Encryptors; import org.springframework.social.config.annotation.EnableSocial; import org.springframework.social.config.annotation.SocialConfigurerAdapter; import org.springframework.social.connect.ConnectionFactoryLocator; import org.springframework.social.connect.jdbc.JdbcUsersConnectionRepository; import org.springframework.social.security.SpringSocialConfigurer; import javax.sql.DataSource; @Configuration @EnableSocial public class SocialConfig extends SocialConfigurerAdapter { @Autowired private DataSource dataSource; @Bean @Primary // 父类会默认使用InMemoryUsersConnectionRepository作为实现,我们要使用@Primary告诉容器只使用我们这个 @Override public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) { // 使用第三个参数可以对 token 进行加密存储 return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText()); } }
SocialAuthenticationFilter
万变不离其中,使用第三方登录的流程和用户名密码的认证流程其实是一样的。只不过后者是根据用户输入的用户名到用户表中查找用户;而前者是先走OAtuh流程拿到用户在第三方应用中的providerUserId
,再根据providerId
和providerUserId
到UserConnection
表中查询对应的userId
,最后根据userId
到用户表中查询用户
因此我们还需要启用SocialAuthenticationFilter
:
package top.zhenganwen.security.core.social.qq; @Configuration @EnableSocial public class SocialConfig extends SocialConfigurerAdapter { @Autowired private DataSource dataSource; @Override public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) { // 使用第三个参数可以对 token 进行加密存储 return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText()); } // 该bean是联合登录配置类,和我们之前所写的SmsLoginConfig和VerifyCodeValidatorConfig的 // 的作用是一样的,只不过它是增加一个SocialAuthenticationFilter到过滤器链中 @Bean public SpringSocialConfigurer springSocialConfigurer() { return new SpringSocialConfigurer(); } }
SecurityBrowserConfig
@Override protected void configure(HttpSecurity http) throws Exception { // 启用验证码校验过滤器 http.apply(verifyCodeValidatorConfig); // 启用短信登录过滤器 http.apply(smsLoginConfig); // 启用QQ登录(将SocialAuthenticationFilter加入到Security过滤器链中) http.apply(springSocialConfigurer); ...
appId & appSecret & providerId
由于每个系统申请的appId
和appSecret
都不同,所以我们将其抽取到了配置文件中
demo.security.qq.appId=YOUR_APP_ID #替换成你的appId demo.security.qq.appSecret=YOUR_APP_SECRET #替换成你的appSecret demo.security.qq.providerId=qq
联合登录URL设置规则
我们需要在登录页提供一个QQ联合登录的链接,请求为/auth/qq
<a href="/auth/qq">qq登录</a>
第一个路径/auth
是应为SocialAuthenticationFilter
默认拦截/auth
开头的请求
SocialAuthenticationFilter
private static final String DEFAULT_FILTER_PROCESSES_URL = "/auth";
第二个路径需要和providerId
保持一致,而我们配置的demo.security.qq.provider-id
为qq
SocialAuthenticationFilter
@Deprecated protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { String providerId = getRequestedProviderId(request); if (providerId != null){ Set<String> authProviders = authServiceLocator.registeredAuthenticationProviderIds(); return authProviders.contains(providerId); } return false; }
联合登录URL需和回调域保持一致
现在SpringSocial的各个组件我们算是实现了,但是能否串起来走通整个流程,我们可以来试一下,并在逐步排错的过程中进一步理解Social认证的流程
访问/login.html
,点击qq登录
后响应如下
提示我们回调地址是非法的,我们可以看一下地址栏中的redirect_url
参数
转码后其实就是http://localhost:8080/auth/qq
,也就是说如果用户同意授权那么浏览器将会重定向到联合登录的URL上。
而我在QQ互联中申请时填写的回调域是www.zhenganwen.top/socialLogin/qq
(如下图),QQ联合登录要求用户同意授权之后重定向到的URL必须和申请appId时填写的回调域保持一致,也就是说页面上联合登录的URL必须和回调域保持一致。
首先域名和端口需要保持一致:
由于是本地服务器,因此我们需要修改本地hosts
文件,让浏览器解析www.zhenganwen.top
时解析到172.0.0.1
:
127.0.0.1 www.zhenganwen.top
并且将服务端口改为80
server.port=80
这样域名和端口能对应上了,能够通过www.zhenganwen.top/login.html
访问登录页。
其次,还需要将联合登录URI和我们在设置的回调域对应上,/auth
改为/socialLogin
,需要自定义SocialAuthenticationFilter
的filterProcessesUrl
属性值:
新增SocialProperties
package top.zhenganwen.security.core.properties; import lombok.Data; import top.zhenganwen.security.core.social.qq.connect.QQSecurityPropertie; @Data public class SocialProperties { public static final String DEFAULT_FILTER_PROCESSING_URL = "/socialLogin"; private QQSecurityPropertie qq = new QQSecurityPropertie(); private String filterProcessingUrl = DEFAULT_FILTER_PROCESSING_URL; }
修改SecurityProperties
@Data @ConfigurationProperties(prefix = "demo.security") public class SecurityProperties { private BrowserProperties browser = new BrowserProperties(); private VerifyCodeProperties code = new VerifyCodeProperties(); // private QQSecurityPropertie qq = new QQSecurityPropertie(); private SocialProperties social = new SocialProperties(); }
application.properties
同步修改:
#demo.security.qq.appId=*** #demo.security.qq.appSecret=*** #demo.security.qq.providerId=qq demo.security.social.qq.appId=*** demo.security.social.qq.appSecret=*** demo.security.social.qq.providerId=qq
QQLoginAutoConfig
同步修改
@Component //@ConditionalOnProperty(prefix = "demo.security.qq",name = "appId") @ConditionalOnProperty(prefix = "demo.security.social.qq",name = "appId") public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter {
扩展SpringSocialConfigurer
,通过钩子函数postProcess
来实现对SocialAuthenticationFilter
的一些自定义配置,如filterProcessingUrl
package top.zhenganwen.security.core.social.qq.connect; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.social.security.SocialAuthenticationFilter; import org.springframework.social.security.SpringSocialConfigurer; import top.zhenganwen.security.core.properties.SecurityProperties; public class QQSpringSocialConfigurer extends SpringSocialConfigurer { @Autowired private SecurityProperties securityProperties; @Override protected <T> T postProcess(T object) { SocialAuthenticationFilter filter = (SocialAuthenticationFilter) object; filter.setFilterProcessesUrl(securityProperties.getSocial().getFilterProcessingUrl()); return (T) filter; } }
在SocialConfig
注入扩展后的SpringSocialConfigurer
@Configuration @EnableSocial public class SocialConfig extends SocialConfigurerAdapter { @Autowired private DataSource dataSource; @Override public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) { // 使用第三个参数可以对 token 进行加密存储 return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText()); } // @Bean // public SpringSocialConfigurer springSocialConfigurer() { // return new SpringSocialConfigurer(); // } @Bean public SpringSocialConfigurer qqSpringSocialConfigurer() { QQSpringSocialConfigurer qqSpringSocialConfigurer = new QQSpringSocialConfigurer(); return qqSpringSocialConfigurer; } }
这样做的原因是postProcess()
是一个钩子函数,在SecurityConfigurerAdapter
的config
方法中,在将SocialAuthenticationFilter
加入到过滤器链中时会调用postProcess
,允许子类重写该方法从而对SocialAuthenticationFilter
进行一些自定义配置:
public class SpringSocialConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Override public void configure(HttpSecurity http) throws Exception { ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class); UsersConnectionRepository usersConnectionRepository = getDependency(applicationContext, UsersConnectionRepository.class); SocialAuthenticationServiceLocator authServiceLocator = getDependency(applicationContext, SocialAuthenticationServiceLocator.class); SocialUserDetailsService socialUsersDetailsService = getDependency(applicationContext, SocialUserDetailsService.class); SocialAuthenticationFilter filter = new SocialAuthenticationFilter( http.getSharedObject(AuthenticationManager.class), userIdSource != null ? userIdSource : new AuthenticationNameUserIdSource(), usersConnectionRepository, authServiceLocator); ... http.authenticationProvider( new SocialAuthenticationProvider(usersConnectionRepository, socialUsersDetailsService)) .addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class); } protected <T> T postProcess(T object) { return (T) this.objectPostProcessor.postProcess(object); } }
同步修改登录页
<a href="/socialLogin/qq">qq登录</a>
同时要在联合登录配置类中将该联合登录URL的拦截放开
public class QQSpringSocialConfigurer extends SpringSocialConfigurer { @Autowired private SecurityProperties securityProperties; @Override protected <T> T postProcess(T object) { SocialAuthenticationFilter filter = (SocialAuthenticationFilter) object; filter.setFilterProcessesUrl(securityProperties.getSocial().getFilterProcessingUrl()); return (T) filter; } @Override public void configure(HttpSecurity http) throws Exception { super.configure(http); http.authorizeRequests() .mvcMatchers(securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId()) .permitAll(); } }
访问www.zhenganwen.top/login.html
,点击qq登录
发现跳转如下
授权跳转逻辑走通!该阶段代码可参见:https://gitee.com/zhenganwen/code-demo/tree/21d708b6a45cbf2baab322470d96313f08b0c426/
阶段性小结
回调域解析
你是在本地80端口跑的服务,为什么认证服务器能够解析回调域www.zhenganwen.top/socialLogin/qq
中的域名从而跳转到你的本地
注意上面授权登录页面的地址栏,URL附带了redirect_url
这一参数,因此当你同意授权登陆后,跳转到redirect_url
参数值这一操作是在你浏览器中进行的,而你在hosts
中配置了127.0.0.1 www.zhenganwen.top
,因此浏览器没有进行域名解析直接将请求/socialLogin/qq
发送到了127.0.0.1:80
上,也就是我们正在运行的security-demo
服务
SpringSoicalConfigure的作用是什么?
直接上源码:
public class SpringSocialConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Override public void configure(HttpSecurity http) throws Exception { ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class); UsersConnectionRepository usersConnectionRepository = getDependency(applicationContext, UsersConnectionRepository.class); SocialAuthenticationServiceLocator authServiceLocator = getDependency(applicationContext, SocialAuthenticationServiceLocator.class); SocialUserDetailsService socialUsersDetailsService = getDependency(applicationContext, SocialUserDetailsService.class); SocialAuthenticationFilter filter = new SocialAuthenticationFilter( http.getSharedObject(AuthenticationManager.class), userIdSource != null ? userIdSource : new AuthenticationNameUserIdSource(), usersConnectionRepository, authServiceLocator); ... http.authenticationProvider( new SocialAuthenticationProvider(usersConnectionRepository, socialUsersDetailsService)) .addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class); } }
如果我们想将之前所写的SpringSoical组件都应用上,那就要遵循SpringSecurity的认证机制,即添加一个新的认证方式就需要添加一个XxxAuthenticationFilter
,而SpringSoical已经帮我们实现了SocialAuthenticationFilter
,因此我们只需要在过滤器中添加它就行。与我们之前将短信登录封装到SmsLoginConfig
中一样,SpringSocial帮我们将社交登录封装到了SpringSocialConfigure
中,这样只要业务系统(即依赖SpringSocial的应用)只需调用httpSecurity.apply(springSocialConfigure)
即可启用社交登录功能。
并且除了将SoicalAuthenticationFilter
添加到过滤器链中之外,SpringSocialConfigure
还会将容器中的UsersConnectionRepository
和SocialAuthenticationServiceLocator
关联到SoicalAuthenticationFilter
中,SoicalAuthenticationFilter
通过前者能够根据OAuth流程获取的社交信息(providerId
和providerUserId
)查询到userId
,通过后者能够根据providerId
获取对应的SocialAuthenticationService
并从中获取到ConnectionFactory
进行获取授权码、获取accessToken
、获取用户社交信息等操作
public interface UsersConnectionRepository { List<String> findUserIdsWithConnection(Connection<?> connection); }
public interface SocialAuthenticationServiceLocator extends ConnectionFactoryLocator { SocialAuthenticationService<?> getAuthenticationService(String providerId); }
public interface SocialAuthenticationService<S> { ConnectionFactory<S> getConnectionFactory(); SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException; }
为什么要有SocialAuthenticationService,是在什么时候产生的?
SocialAuthenticationService
是对ConnectionFactory
的一个封装,对SocialAuthenticationFilter
隐藏OAuth以及OpenAPI调用细节
因为我们在SocialConfig
中添加了@EnableSocial
,所以在系统启动时会根据SocialAutoConfigurerAdapter
实现类中的createConnectionFactory
创建对应不同社交系统的ConnectionFactory
并将其包装成SocialAuthenticationService
,然后将所有的SocialAuthenticationService
以providerId
为key
缓存在SocialAuthenticationLocator
中
@Component //@ConditionalOnProperty(prefix = "demo.security.qq",name = "appId") @ConditionalOnProperty(prefix = "demo.security.social.qq",name = "appId") public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter { public static final String URL_TO_GET_AUTHORIZATION_CODE = "https://graph.qq.com/oauth2.0/authorize"; public static final String URL_TO_GET_TOKEN = "https://graph.qq.com/oauth2.0/token"; @Autowired private SecurityProperties securityProperties; @Autowired private QQConnectionAdapter qqConnectionAdapter; @Override protected ConnectionFactory<?> createConnectionFactory() { return new QQConnectionFactory( securityProperties.getSocial().getQq().getProviderId(), new QQServiceProvider(oAuth2Operations(), securityProperties.getSocial().getQq().getAppId()), qqConnectionAdapter); } @Bean public OAuth2Operations oAuth2Operations() { return new OAuth2Template( securityProperties.getSocial().getQq().getAppId(), securityProperties.getSocial().getQq().getAppSecret(), URL_TO_GET_AUTHORIZATION_CODE, URL_TO_GET_TOKEN); } }
class SecurityEnabledConnectionFactoryConfigurer implements ConnectionFactoryConfigurer { private SocialAuthenticationServiceRegistry registry; public SecurityEnabledConnectionFactoryConfigurer() { registry = new SocialAuthenticationServiceRegistry(); } public void addConnectionFactory(ConnectionFactory<?> connectionFactory) { registry.addAuthenticationService(wrapAsSocialAuthenticationService(connectionFactory)); } public ConnectionFactoryRegistry getConnectionFactoryLocator() { return registry; } private <A> SocialAuthenticationService<A> wrapAsSocialAuthenticationService(ConnectionFactory<A> cf) { if (cf instanceof OAuth1ConnectionFactory) { return new OAuth1AuthenticationService<A>((OAuth1ConnectionFactory<A>) cf); } else if (cf instanceof OAuth2ConnectionFactory) { final OAuth2AuthenticationService<A> authService = new OAuth2AuthenticationService<A>((OAuth2ConnectionFactory<A>) cf); authService.setDefaultScope(((OAuth2ConnectionFactory<A>) cf).getScope()); return authService; } throw new IllegalArgumentException("The connection factory must be one of OAuth1ConnectionFactory or OAuth2ConnectionFactory"); } }
public class SocialAuthenticationServiceRegistry extends ConnectionFactoryRegistry implements SocialAuthenticationServiceLocator { private Map<String, SocialAuthenticationService<?>> authenticationServices = new HashMap<String, SocialAuthenticationService<?>>(); public SocialAuthenticationService<?> getAuthenticationService(String providerId) { SocialAuthenticationService<?> authenticationService = authenticationServices.get(providerId); if (authenticationService == null) { throw new IllegalArgumentException("No authentication service for service provider '" + providerId + "' is registered"); } return authenticationService; } public void addAuthenticationService(SocialAuthenticationService<?> authenticationService) { addConnectionFactory(authenticationService.getConnectionFactory()); authenticationServices.put(authenticationService.getConnectionFactory().getProviderId(), authenticationService); } public void setAuthenticationServices(Iterable<SocialAuthenticationService<?>> authenticationServices) { for (SocialAuthenticationService<?> authenticationService : authenticationServices) { addAuthenticationService(authenticationService); } } public Set<String> registeredAuthenticationProviderIds() { return authenticationServices.keySet(); } }
所以当SocialAuthenticationFilter
拦截到/{filterProcessingUrl}/{providerId}
之后,会根据出URL路径中的providerId
到SocialAuthenticationLocator
中查找对应的SocialAuthenticationService
获取authRequest
public class SocialAuthenticationFilter extends AbstractAuthenticationProcessingFilter { @Deprecated protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { String providerId = getRequestedProviderId(request); if (providerId != null){ Set<String> authProviders = authServiceLocator.registeredAuthenticationProviderIds(); return authProviders.contains(providerId); } return false; } public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (detectRejection(request)) { if (logger.isDebugEnabled()) { logger.debug("A rejection was detected. Failing authentication."); } throw new SocialAuthenticationException("Authentication failed because user rejected authorization."); } Authentication auth = null; Set<String> authProviders = authServiceLocator.registeredAuthenticationProviderIds(); String authProviderId = getRequestedProviderId(request); if (!authProviders.isEmpty() && authProviderId != null && authProviders.contains(authProviderId)) { SocialAuthenticationService<?> authService = authServiceLocator.getAuthenticationService(authProviderId); auth = attemptAuthService(authService, request, response); if (auth == null) { throw new AuthenticationServiceException("authentication failed"); } } return auth; } }
为什么社交登录URL和回调域要保持一致
SocialAuthenticationFilter#attemptAuthService
private Authentication attemptAuthService(final SocialAuthenticationService<?> authService, final HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException, AuthenticationException { final SocialAuthenticationToken token = authService.getAuthToken(request, response); if (token == null) return null; Assert.notNull(token.getConnection()); Authentication auth = getAuthentication(); if (auth == null || !auth.isAuthenticated()) { return doAuthentication(authService, request, token); } else { addConnection(authService, request, token, auth); return null; } }
OAuth2AuthenticationService#getAuthToken
public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException { String code = request.getParameter("code"); if (!StringUtils.hasText(code)) { OAuth2Parameters params = new OAuth2Parameters(); params.setRedirectUri(buildReturnToUrl(request)); setScope(request, params); params.add("state", generateState(connectionFactory, request)); addCustomParameters(params); throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params)); } else if (StringUtils.hasText(code)) { try { String returnToUrl = buildReturnToUrl(request); AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null); // TODO avoid API call if possible (auth using token would be fine) Connection<S> connection = getConnectionFactory().createConnection(accessGrant); return new SocialAuthenticationToken(connection, null); } catch (RestClientException e) { logger.debug("failed to exchange for access", e); return null; } } else { return null; } }
可以发现,用户在登录也上点击qq
登录时被SocialAuthenticationFilter
拦截,进入到上述的getAuthToken
方法,请求参数是不带授权码的,因此第9
行会抛出异常,该异常会被认证失败处理器截获并将用户导向社交系统认证服务器
public class SocialAuthenticationFailureHandler implements AuthenticationFailureHandler { private AuthenticationFailureHandler delegate; public SocialAuthenticationFailureHandler(AuthenticationFailureHandler delegate) { this.delegate = delegate; } public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { if (failed instanceof SocialAuthenticationRedirectException) { response.sendRedirect(((SocialAuthenticationRedirectException)failed).getRedirectUrl()); } else { this.delegate.onAuthenticationFailure(request, response, failed); } } }
在用户同意授权后,认证服务器跳转到回调域并带入授权码,这时就会进入getAuthToken
的第11
行,拿授权码获取accessToken
(AccessGrant
)、调用OpenAPI获取用户信息并适配成Connection
为什么同意授权后响应如下
我们扫描二维码同意授权,浏览器重定向到/socialLogin/qq
之后,发生了什么
public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException { String code = request.getParameter("code"); if (!StringUtils.hasText(code)) { OAuth2Parameters params = new OAuth2Parameters(); params.setRedirectUri(buildReturnToUrl(request)); setScope(request, params); params.add("state", generateState(connectionFactory, request)); addCustomParameters(params); throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params)); } else if (StringUtils.hasText(code)) { try { String returnToUrl = buildReturnToUrl(request); AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null); // TODO avoid API call if possible (auth using token would be fine) Connection<S> connection = getConnectionFactory().createConnection(accessGrant); return new SocialAuthenticationToken(connection, null); } catch (RestClientException e) { logger.debug("failed to exchange for access", e); return null; } } else { return null; } }
在上述带啊的第12
行打断点进行跟踪一下,发现执行13
行时抛出异常跳转到了18
行,异常信息如下:
org.springframework.web.client.RestClientException: Could not extract response: no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]
说明是在调用我们的OAuth2Template
的exchangeForAccess
拿授权码获取accessToken
时报错了,错误原因是在转换响应结果为AccessGrant
时没有处理text/html
的转换器。
首先我们看一下响应结果是什么:
发现响应结果是一个字符串,以&
分割三个键值对,而OAuth2Template
默认提供的转换器如下:
OAuth2Template
protected RestTemplate createRestTemplate() { ClientHttpRequestFactory requestFactory = ClientHttpRequestFactorySelector.getRequestFactory(); RestTemplate restTemplate = new RestTemplate(requestFactory); List<HttpMessageConverter<?>> converters = new ArrayList<HttpMessageConverter<?>>(2); converters.add(new FormHttpMessageConverter()); converters.add(new FormMapHttpMessageConverter()); converters.add(new MappingJackson2HttpMessageConverter()); restTemplate.setMessageConverters(converters); restTemplate.setErrorHandler(new LoggingErrorHandler()); if (!useParametersForClientAuthentication) { List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors(); if (interceptors == null) { // defensively initialize list if it is null. (See SOCIAL-430) interceptors = new ArrayList<ClientHttpRequestInterceptor>(); restTemplate.setInterceptors(interceptors); } interceptors.add(new PreemptiveBasicAuthClientHttpRequestInterceptor(clientId, clientSecret)); } return restTemplate; }
查看上述5~7
行的3个转换器,FormHttpMessageConverter
、FormMapHttpMessageConverter
、MappingJackson2HttpMessageConverter
分别对应解析Content-Type
为application/x-www-form-urlencoded
、multipart/form-data
、application/json
的响应体,因此报错提示
no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]
这时我们需要在原有的OAuth2Template
的基础上在增加一个处理text/html
的转换器:
public class QQOAuth2Template extends OAuth2Template { public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) { super(clientId, clientSecret, authorizeUrl, accessTokenUrl); setUseParametersForClientAuthentication(true); } /** * 添加消息转换器以使能够解析 Content-Type 为 text/html 的响应体 * StringHttpMessageConverter 可解析任何 Content-Type的响应体,见其构造函数 * @return */ @Override protected RestTemplate createRestTemplate() { RestTemplate restTemplate = super.createRestTemplate(); restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8"))); return restTemplate; } /** * 如果响应体是json,OAuth2Template会帮我们构建, 但QQ互联的OpenAPI返回包都是 text/html 字符串 * 响应体 : "access_token=FE04***********CCE2&expires_in=7776000&refresh_token=88E4********BE14" * 使用 StringHttpMessageConverter 将请求的响应体转成 String ,并手动构建 AccessGrant * @param accessTokenUrl 拿授权码获取accessToken的URL * @param parameters 请求 accessToken 需要附带的参数 * @return */ @Override protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) { String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters,String.class); if (StringUtils.isEmpty(responseStr)) { return null; } // 0 -> access_token=FE04***********CCE // 1 -> expires_in=7776000 // 2 -> refresh_token=88E4********BE14 String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&"); // accessToken scope refreshToken expiresIn AccessGrant accessGrant = new AccessGrant( StringUtils.substringAfterLast(strings[0], "="), null, StringUtils.substringAfterLast(strings[2], "="), Long.valueOf(StringUtils.substringAfterLast(strings[1], "="))); return accessGrant; } }
使用该QQOAuth2Template
替换之前注入的OAuth2Template
@Component //@ConditionalOnProperty(prefix = "demo.security.qq",name = "appId") @ConditionalOnProperty(prefix = "demo.security.social.qq",name = "appId") public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter { public static final String URL_TO_GET_AUTHORIZATION_CODE = "https://graph.qq.com/oauth2.0/authorize"; public static final String URL_TO_GET_TOKEN = "https://graph.qq.com/oauth2.0/token"; @Autowired private SecurityProperties securityProperties; @Autowired private QQConnectionAdapter qqConnectionAdapter; @Override protected ConnectionFactory<?> createConnectionFactory() { return new QQConnectionFactory( securityProperties.getSocial().getQq().getProviderId(), new QQServiceProvider(oAuth2Operations(), securityProperties.getSocial().getQq().getAppId()), qqConnectionAdapter); } // @Bean // public OAuth2Operations oAuth2Operations() { // return new OAuth2Template( // securityProperties.getSocial().getQq().getAppId(), // securityProperties.getSocial().getQq().getAppSecret(), // URL_TO_GET_AUTHORIZATION_CODE, // URL_TO_GET_TOKEN); // } @Bean public OAuth2Operations oAuth2Operations() { return new QQOAuth2Template( securityProperties.getSocial().getQq().getAppId(), securityProperties.getSocial().getQq().getAppSecret(), URL_TO_GET_AUTHORIZATION_CODE, URL_TO_GET_TOKEN); } }
现在我们能够拿到封装accessToken
的AccessGrant
了,再继续端点调试Connection
的获取(下述第15
行)
OAuth2AuthenticationService
public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException { String code = request.getParameter("code"); if (!StringUtils.hasText(code)) { OAuth2Parameters params = new OAuth2Parameters(); params.setRedirectUri(buildReturnToUrl(request)); setScope(request, params); params.add("state", generateState(connectionFactory, request)); addCustomParameters(params); throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params)); } else if (StringUtils.hasText(code)) { try { String returnToUrl = buildReturnToUrl(request); AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null); // TODO avoid API call if possible (auth using token would be fine) Connection<S> connection = getConnectionFactory().createConnection(accessGrant); return new SocialAuthenticationToken(connection, null); } catch (RestClientException e) { logger.debug("failed to exchange for access", e); return null; } } else { return null; } }
发现QQApiImpl
的getUserInfo
存在同一的问题,调用QQ互联API响应类型都是text/html
,因此我们不能直接转成POJO,而要先获取响应串,在通过JSON转换工具类ObjectMapper
来转换:
QQApiImpl
@Override public QQUserInfo getUserInfo() { // QQ互联的响应 Content-Type 都是 text/html,因此不能直接转为 QQUserInfo // QQUserInfo qqUserInfo = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), QQUserInfo.class); String responseStr = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), String.class); logger.info("调用QQ OpenAPI获取用户信息: {}", responseStr); try { QQUserInfo qqUserInfo = objectMapper.readValue(responseStr, QQUserInfo.class); qqUserInfo.setOpenId(openId); return qqUserInfo; } catch (Exception e) { logger.error("获取用户信息转成 QQUserInfo 失败,响应信息:{}", responseStr); return null; } }
再次扫码登录进行断点调试,发现Connection
也能成功拿到了,并且封装成SocialAuthenticationToken
返回,于是getAuthToken
终于成功返回了,走到了doAuthentication
SocialAuthenticationFilter
private Authentication attemptAuthService(final SocialAuthenticationService<?> authService, final HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException, AuthenticationException { final SocialAuthenticationToken token = authService.getAuthToken(request, response); if (token == null) return null; Assert.notNull(token.getConnection()); Authentication auth = getAuthentication(); if (auth == null || !auth.isAuthenticated()) { return doAuthentication(authService, request, token); } else { addConnection(authService, request, token, auth); return null; } } private Authentication doAuthentication(SocialAuthenticationService<?> authService, HttpServletRequest request, SocialAuthenticationToken token) { try { if (!authService.getConnectionCardinality().isAuthenticatePossible()) return null; token.setDetails(authenticationDetailsSource.buildDetails(request)); Authentication success = getAuthenticationManager().authenticate(token); Assert.isInstanceOf(SocialUserDetails.class, success.getPrincipal(), "unexpected principal type"); updateConnections(authService, token, success); return success; } catch (BadCredentialsException e) { // connection unknown, register new user? if (signupUrl != null) { // store ConnectionData in session and redirect to register page sessionStrategy.setAttribute(new ServletWebRequest(request), ProviderSignInAttempt.SESSION_ATTRIBUTE, new ProviderSignInAttempt(token.getConnection())); throw new SocialAuthenticationRedirectException(buildSignupUrl(request)); } throw e; } }
这时会调用ProviderManager
的authenticate
对SocialAuthenticationToken
进行校验,ProviderManager
又会委托SocialAuthenticationProvider
SocialAuthenticationProvider
会调用我们注入的JdbcUsersConnectionRepository
到UserConnection
表中根据Connection
的providerId
和providerUserId
查找userId
public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(SocialAuthenticationToken.class, authentication, "unsupported authentication type"); Assert.isTrue(!authentication.isAuthenticated(), "already authenticated"); SocialAuthenticationToken authToken = (SocialAuthenticationToken) authentication; String providerId = authToken.getProviderId(); Connection<?> connection = authToken.getConnection(); String userId = toUserId(connection); if (userId == null) { throw new BadCredentialsException("Unknown access token"); } UserDetails userDetails = userDetailsService.loadUserByUserId(userId); if (userDetails == null) { throw new UsernameNotFoundException("Unknown connected account id"); } return new SocialAuthenticationToken(connection, userDetails, authToken.getProviderAccountData(), getAuthorities(providerId, userDetails)); } protected String toUserId(Connection<?> connection) { List<String> userIds = usersConnectionRepository.findUserIdsWithConnection(connection); // only if a single userId is connected to this providerUserId return (userIds.size() == 1) ? userIds.iterator().next() : null; }
JdbcUsersConnectionRepository
public List<String> findUserIdsWithConnection(Connection<?> connection) { ConnectionKey key = connection.getKey(); List<String> localUserIds = jdbcTemplate.queryForList("select userId from " + tablePrefix + "UserConnection where providerId = ? and providerUserId = ?", String.class, key.getProviderId(), key.getProviderUserId()); if (localUserIds.size() == 0 && connectionSignUp != null) { String newUserId = connectionSignUp.execute(connection); if (newUserId != null) { createConnectionRepository(newUserId).addConnection(connection); return Arrays.asList(newUserId); } } return localUserIds; }
由于找不到(因为这时我们的UserConnection
表压根就没数据),toUserId
会返回null
,接着抛出BadCredentialsException("Unknown access token")
,该异常会被SocialAuthenticationFilter
捕获,并根据其signupUrl
属性进行重定向(SpringSocial认为该用户在本系统没有注册,或者注册了但没有将本地用户和QQ登录关联,因此跳转到注册页)
private Authentication doAuthentication(SocialAuthenticationService<?> authService, HttpServletRequest request, SocialAuthenticationToken token) { try { if (!authService.getConnectionCardinality().isAuthenticatePossible()) return null; token.setDetails(authenticationDetailsSource.buildDetails(request)); Authentication success = getAuthenticationManager().authenticate(token); Assert.isInstanceOf(SocialUserDetails.class, success.getPrincipal(), "unexpected principal type"); updateConnections(authService, token, success); return success; } catch (BadCredentialsException e) { // connection unknown, register new user? if (signupUrl != null) { // store ConnectionData in session and redirect to register page sessionStrategy.setAttribute(new ServletWebRequest(request), ProviderSignInAttempt.SESSION_ATTRIBUTE, new ProviderSignInAttempt(token.getConnection())); throw new SocialAuthenticationRedirectException(buildSignupUrl(request)); } throw e; } }
而SocialAuthenticationFilter
的signupUrl
默认为/signup
public class SocialAuthenticationFilter extends AbstractAuthenticationProcessingFilter { private String signupUrl = "/signup"; }
跳转到/signup
时,被SpringSecurity拦截,并重定向到loginPage()
,最后到了BrowserSecurityController
SecurityBrowserConfig
.formLogin() .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
SecurityConstants
/** * 未登录访问受保护URL则跳转路径到 此 */ String FORWARD_TO_LOGIN_PAGE_URL = "/auth/require";
BrowserSecurityController
@RestController public class BrowserSecurityController { private Logger logger = LoggerFactory.getLogger(getClass()); // security会将跳转前的请求存储在session中 private RequestCache requestCache = new HttpSessionRequestCache(); private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); @Autowired SecurityProperties securityProperties; @RequestMapping("/auth/require") @ResponseStatus(code = HttpStatus.UNAUTHORIZED) public SimpleResponseResult requireAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException { SavedRequest savedRequest = requestCache.getRequest(request, response); if (savedRequest != null) { String redirectUrl = savedRequest.getRedirectUrl(); logger.info("引发跳转到/auth/login的请求是: {}", redirectUrl); if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) { // 如果用户是访问html页面被FilterSecurityInterceptor拦截从而跳转到了/auth/login,那么就重定向到登录页面 redirectStrategy.sendRedirect(request, response, securityProperties.getBrowser().getLoginPage()); } } // 如果不是访问html而被拦截跳转到了/auth/login,则返回JSON提示 return new SimpleResponseResult("用户未登录,请引导用户至登录页"); } }
于是最终得到了如下响应:
@EnableSocial做了些什么
它会加载一个配置类SocialConfiguration
,该类会读取容器中SocialConfigure
实例,如我们所写的扩展SocialAutoConfigureAdapter
的QQLoginAutoConfig
和扩展了SocialConfigureAdapter
的SocialConfig
,将我们实现的ConnectionFactory
和UsersConnectionRepository
与SpringSecurity
的认证流程串起来
/** * Configuration class imported by {@link EnableSocial}. * @author Craig Walls */ @Configuration public class SocialConfiguration { private static boolean securityEnabled = isSocialSecurityAvailable(); @Autowired private Environment environment; private List<SocialConfigurer> socialConfigurers; @Autowired public void setSocialConfigurers(List<SocialConfigurer> socialConfigurers) { Assert.notNull(socialConfigurers, "At least one configuration class must implement SocialConfigurer (or subclass SocialConfigurerAdapter)"); Assert.notEmpty(socialConfigurers, "At least one configuration class must implement SocialConfigurer (or subclass SocialConfigurerAdapter)"); this.socialConfigurers = socialConfigurers; } @Bean public ConnectionFactoryLocator connectionFactoryLocator() { if (securityEnabled) { SecurityEnabledConnectionFactoryConfigurer cfConfig = new SecurityEnabledConnectionFactoryConfigurer(); for (SocialConfigurer socialConfigurer : socialConfigurers) { socialConfigurer.addConnectionFactories(cfConfig, environment); } return cfConfig.getConnectionFactoryLocator(); } else { DefaultConnectionFactoryConfigurer cfConfig = new DefaultConnectionFactoryConfigurer(); for (SocialConfigurer socialConfigurer : socialConfigurers) { socialConfigurer.addConnectionFactories(cfConfig, environment); } return cfConfig.getConnectionFactoryLocator(); } } @Bean public UsersConnectionRepository usersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) { UsersConnectionRepository usersConnectionRepository = null; for (SocialConfigurer socialConfigurer : socialConfigurers) { UsersConnectionRepository ucrCandidate = socialConfigurer.getUsersConnectionRepository(connectionFactoryLocator); if (ucrCandidate != null) { usersConnectionRepository = ucrCandidate; break; } } Assert.notNull(usersConnectionRepository, "One configuration class must implement getUsersConnectionRepository from SocialConfigurer."); return usersConnectionRepository; } }
注册页 & 关联社交账号
首先将注册页的URL可配置化,默认设为/sign-up.html
,以及处理注册的服务接口/user/register
@Data public class SocialProperties { private QQSecurityPropertie qq = new QQSecurityPropertie(); public static final String DEFAULT_FILTER_PROCESSING_URL = "/socialLogin"; private String filterProcessingUrl = DEFAULT_FILTER_PROCESSING_URL; public static final String DEFAULT_SIGN_UP_URL = "/sign-up.html"; private String signUpUrl = DEFAULT_SIGN_UP_URL; public static final String DEFAULT_SING_UP_PROCESSING_URL = "/user/register"; private String signUpProcessingUrl = DEFAULT_SING_UP_PROCESSING_URL; }
然后在浏览器配置类中将此路径放开:
@Autowired private SpringSocialConfigurer qqSpringSocialConfigurer; @Override protected void configure(HttpSecurity http) throws Exception { // 启用验证码校验过滤器 http.apply(verifyCodeValidatorConfig).and() // 启用短信登录过滤器 .apply(smsLoginConfig).and() // 启用QQ登录 .apply(qqSpringSocialConfigurer).and() // 启用表单密码登录过滤器 .formLogin() .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL) .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL) .successHandler(customAuthenticationSuccessHandler) .failureHandler(customAuthenticationFailureHandler) .and() // 浏览器应用特有的配置,将登录后生成的token保存在cookie中 .rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(3600) .userDetailsService(customUserDetailsService) .and() // 浏览器应用特有的配置 .authorizeRequests() .antMatchers( SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL, securityProperties.getBrowser().getLoginPage(), SecurityConstants.VERIFY_CODE_SEND_URL, securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(), securityProperties.getSocial().getSignUpUrl(), securityProperties.getSocial().getSignUpProcessingUrl()).permitAll() .anyRequest().authenticated().and() .csrf().disable(); }
最后编写注册页:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <h1>标准注册页</h1> <a href="/social">QQ账号信息</a> <form action="/user/register" method="post"> 用户名: <input type="text" name="username" value="admin"> 密码: <input type="password" name="password" value="123"> <button type="submit" name="type" value="register">注册并关联QQ登录</button> <button type="submit" name="type" value="binding">已有账号关联QQ登录</button> </form> </body> </html>
ProviderSignInUtils
注册服务:虽然因为在UserConnection
表中没有和本地用户关联的记录而跳转到了注册页,但是获取的Connection
或保存在Session
中,如果你想在用户点击注册本地账号时自动为其关联QQ账号或用户已有本地账号自己手动关联QQ账号,那么可以使用ProviderSignInUtils
这个工具类,你只需要告诉其需要关联的本地账户userId
,它会自动取出Session
中保存的Connection
,并将userId
、Connection.getProviderId
、Connection.getProviderUserId
作为一条记录插入到数据库中,这样该用户下次再进行QQ登录时就不会跳转到本地账号注册页了
@RestController @RequestMapping("/user") public class UserController { private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private UserService userService; @Autowired private ProviderSignInUtils providerSignInUtils; @PostMapping("/register") public String register(String username, String password, String type, HttpServletRequest request) { if ("register".equalsIgnoreCase(type)) { logger.info("新增用户并关联QQ登录, 用户名:{}", username); userService.insertUser(); } else if ("binding".equalsIgnoreCase(type)) { logger.info("给用户关联QQ登录, 用户名:{}", username); } providerSignInUtils.doPostSignUp(username, new ServletWebRequest(request)); return "success"; } }
绑定/解绑场景支持
有时我们的系统的账号管理模块需要允许用户关联或取消关联一些社交账号,SpringSocial对这一场景也提供了支持(见ConnectController
)。你只需自定义相关的视图组件(可扩展AbstractView
)便可实现“绑定/解绑”功能。
Session管理
单机Session管理
事实上,我们所自定义的登录流程只会在登录时被执行一次,登录成功后会生成一个封装认证信息的Authentication
保存在本地线程保险箱中,而在后续的用户访问受保护URL等操作时就不会在涉及到这些登录流程中的组件了。
让我们再回想一下Spring Security的过滤器链,位于首位的是SecurityContextPersistenceFilter
,它用于在收到请求时试图从Session中读取登录成功后生成的认证信息放入当前线程保险箱中,在响应请求时再取出来放入Session中,而位于过滤器链末尾的FilterSecurityInterceptor
会在访问Controller
服务之前校验线程保险箱中的认证信息,因此Session的管理会直接影响到用户此刻能否继续访问受保护URL。
在SpringBoot中,我们可以通过配置项server.session.timeout
(单位秒)来设置Session的有效时长,从而实现用户登录一段时间之后如果还在访问受保护URL则需要重新登陆。
相关代码位于TomcatEmbeddedServletContainerFactory
private void configureSession(Context context) { long sessionTimeout = getSessionTimeoutInMinutes(); context.setSessionTimeout((int) sessionTimeout); if (isPersistSession()) { Manager manager = context.getManager(); if (manager == null) { manager = new StandardManager(); context.setManager(manager); } configurePersistSession(manager); } else { context.addLifecycleListener(new DisablePersistSessionListener()); } } private long getSessionTimeoutInMinutes() { long sessionTimeout = getSessionTimeout(); if (sessionTimeout > 0) { sessionTimeout = Math.max(TimeUnit.SECONDS.toMinutes(sessionTimeout), 1L); } return sessionTimeout; }
SpringBoot会将你配置的秒数转为分钟数,因此你会发现设置了server.session.timeout=10
却发现1分钟后Session才失效导致需要重新登陆的情况。
application.properties
server.session.timeout=10 #设置Session 10秒后过期
不过我们一般设置为几个小时
与未登陆而访问受保护URL不同,Session失效导致无法访问受保护URL应该有不一样的提示(例如:因为长时间没有操作,您登陆的会话已过期,请重新登陆;而不应该提示您还未登录,请先登录),这时我们可以配置http.sessionManage().invalidSessionUrl()
来指定用户登录时间超过server.session.timeout
设定的时长之后用户再访问受保护URL会跳转到的URL,你可以为其配置一个页面或者Controller
来提示用户并引导用户到登录页
SecurityBrowserConfig
protected void configure(HttpSecurity http) throws Exception { // 启用验证码校验过滤器 http.apply(verifyCodeValidatorConfig).and() // 启用短信登录过滤器 .apply(smsLoginConfig).and() // 启用QQ登录 .apply(qqSpringSocialConfigurer).and() // 启用表单密码登录过滤器 .formLogin() .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL) .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL) .successHandler(customAuthenticationSuccessHandler) .failureHandler(customAuthenticationFailureHandler) .and() // 浏览器应用特有的配置,将登录后生成的token保存在cookie中 .rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(3600) .userDetailsService(customUserDetailsService) .and() .sessionManagement() .invalidSessionUrl("/session-invalid.html") .and() // 浏览器应用特有的配置 .authorizeRequests() .antMatchers( SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL, securityProperties.getBrowser().getLoginPage(), SecurityConstants.VERIFY_CODE_SEND_URL, securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(), securityProperties.getSocial().getSignUpUrl(), securityProperties.getSocial().getSignUpProcessingUrl(), "/session-invalid.html").permitAll() .anyRequest().authenticated().and() .csrf().disable(); }
在.sessionManagement()
配置下:
通过.maximumSessions
可以控制一个用户同时可登录的会话数,如果设置为1则可实现后一个登录的人会踢掉前一个登录的人。,通过expiredSessionStrategy
可以为该事件设置一个回调方法(前一个人被挤掉后再访问受保护URL时调用),可通过回调参数获取request
和response
通过.maxSessionsPreventsLogin(true)
可设置若用户已登录,则在其他会话无法再次登录,Session由于timeout
的设置失效或二次登录被阻止,都可以通过.invalidSessionStrategy()
配置一个处理策略
集群Session管理
为了实现高可用和高并发,企业级应用通常会采用集群的方式部署服务,通过网关或***将请求根据轮询算法转发的到特定的服务,这时如果每个服务单独管理自己的Session,那么就会出现重复要求用户登录的情况。我们可以将Session的管理抽离出来存储到一个单独的系统中,spring-session
项目可以帮我们完成这份工作,我们只需告诉它用什么存储系统来存储Session即可。
通常我们使用Redis
来存储Session而不使用Mysql
,原因如下:
SpringSecurity
针对每次请求都会从Session
中读取认证信息,因此读取比较频繁,使用缓存系统速度较快Session
是有有效时间的,如果存储在Mysql
中自己还需定时清理,而Redis
本身就自带缓存数据时效性
安装Redis
官网,下载编译
$ wget http://download.redis.io/releases/redis-5.0.5.tar.gz $ tar xzf redis-5.0.5.tar.gz $ cd redis-5.0.5 $ make MALLOC=libc
如果提示找不到相关命令则需安装相关依赖,
yum install -y gcc g++ gcc-c++ make
启动服务:
./src/redis-server
由于我是在虚拟机CentOS6.5
中安装的,而Redis
默认的保护机制只允许本地访问,要想宿主机或外网访问则需配置./redis.conf
,新增bind 192.168.102.2
(我的宿主机局域网IP)可让宿主机访问IP,这相当于增加一个IP白名单,如果想所有主机都能访问该服务,则可配置bind 0.0.0.0
修改配置后,需要再启动时指定读取该配置文件以使配置项生效:./src/redis-server ./redis.conf &
SpringBoot配置文件
在application.properties
中新增spring.redis.host=192.168.102.101
,可指定SpringBoot
启动时连接该主机的Redis
(默认端口6379),并将之前的排除Redis
自动集成注解去掉
//@SpringBootApplication(exclude = {RedisAutoConfiguration.class,RedisRepositoriesAutoConfiguration.class}) @SpringBootApplication @RestController @EnableSwagger2 public class SecurityDemoApplication { public static void main(String[] args) { SpringApplication.run(SecurityDemoApplication.class, args); } @RequestMapping("/hello") public String hello() { return "hello spring security"; } }
在配置文件总指定将Session
托管给Redis
spring.session.store-type=redis spring.redis.host=192.168.102.101
可支持的托管类型封装在了org.springframework.boot.autoconfigure.session.StoreType
中。
使用集群模式后,之前配置的timeout
和http.sessionManagement()
依然生效。
注意:将Session托管给存储系统之后,要确保写入Session中的Bean是可序列化的,即实现了
Serializable
接口,如果Bean中的属性无法序列化,例如ImageCode
中的BufferedImage image
,如果不需要存储到Session中,则可以在写入Session时将该属性置为null
@Override public void save(ServletWebRequest request, ImageCode imageCode) { ImageCode ic = new ImageCode(imageCode.getCode(), null, imageCode.getExpireTime()); sessionStrategy.setAttribute(request, SecurityConstants.IMAGE_CODE_SESSION_KEY, ic); }
退出登录
如何退出登录
Security
为我们提供了一个默认注销当前用户的服务/logout
,默认会做如下3件事:
- 使当前
Session
失效 - 清除
remember-me
功能的相关信息 - 清除
SecurityContext
中的内容
我们可以通过http.logout()
来自定义注销登录逻辑
logoutUrl()
,指定注销操作请求的URLlogoutSuccessUrl()
,注销完成后跳转到的URLlogoutSuccessHandler()
,注销完成后调用的处理器,可根据用户请求类型动态响应页面或JSONdeleteCookies()
,根据key
删除Cookie
中的item
Spring Security OAuth开发APP认证框架
我们之前所讲的一切都是基于B/S
架构的,即用户通过浏览器直接访问我们的服务,是基于Session/Cookie
的。但是现在前后端分离架构愈发流行,用户可能是直接访问APP或WebServer(如nodejs
),而APP和WebServer再通过ajax
调用后端的服务,这一场景下Session/Cookie
模式会有很多缺点
- 开发繁琐,需要频繁针对
Session/Cookie
进行读写操作,请求从浏览器发出会附带存储在Cookie
中的JSESSIONID
,后端根据这个能够找到对应的Session
,响应时又会将JSESSIONID
写入Cookie
。如果浏览器禁用Cookie
则需在每次的URL上附带JSESSIONID
参数 - 安全性和客户体验差,敏感数据保存在客户端的
Cookie
中不太安全,Session
时效管理、分布式管理等设置不当会导致用户的频繁重新登陆,造成不好的用户体验 - 有些前端技术根本就不支持
Cookie
,如App、小程序
如此而言,Spring Security OAuth
提供了一种基于token
的认证机制,认证不再是每次请求读取存储在Session中的认证信息,而是对授权的用户发放一个token
,访问服务时只需带上token
参数即可。相比较于基于Session
的方式,token
更加灵活和安全,不会向Session
一样SESSIONID
的分配以及参数附带都是固化了的,token
以怎样的形式呈现以及包含哪些信息以及可通过token
刷新机制透明地延长授权时长(用户感知不到)来避免重复登录等,都是可以被我们自定义的。
提到OAuth
,可能很容易联想到之前所开发的第三方登录功能,其实Spring Social
是封装了OAuth
客户端所要走的流程,而Spring Security OAuth
则是封装了OAuth
认证服务器的相关功能。
就我们自己开发的系统而言,后端就是认证服务器和资源服务器,而前端APP以及WebServer等就相当于OAuth
客户端。
认证服务器需要做的事就是提供4中授权模式以及token
的生成和存储,资源服务器就是保护REST
服务,通过过滤器的方式在调用服务前校验请求中的token
。而我们需要做的就是将我们自定义的认证逻辑(用户名密码登录、短信验证码登录、第三方登录)集成到认证服务器中,并对接生成和存储token
。
从本章开始,我们将采用Spring Security OAuth
开发security-app
项目,基于纯OAuth
的认证方式,而不依赖于Session/Cookie
准备工作
首先我们在security-demo
中将引入的security-browser
依赖注释掉,并引入security-app
,忘掉之前基于Session/Cookie
开发的认证代码,从头开始基于OAuth
来开发认证授权。
由于在security-core
中的验证码校验过滤器VerifyCodeValidateFilter
需要注入认证成功/失败处理器,所以我们将security-demo
中的复制一份到security-app
中,并将处理结果以JSON的方式响应(security-browser
的处理结果可以是一个页面,但security-app
只能响应JSON),并将SimpleResponseResult
移入security-core
中。
package top.zhenganwen.securitydemo.app.handler; @Component("appAuthenticationFailureHandler") public class AppAuthenticationFailureHandler 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(new SimpleResponseResult(exception.getMessage()))); response.getWriter().flush(); } }
package top.zhenganwen.securitydemo.app.handler; @Component("appAuthenticationSuccessHandler") public class AppAuthenticationSuccessHandler 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); // } logger.info("用户{}登录成功", authentication.getName()); response.setContentType("application/json;charset=utf-8"); response.getWriter().write(objectMapper.writeValueAsString(authentication)); response.getWriter().flush(); } }
重启服务,查看在去掉security-browser
而引入security-app
之后项目是否能正常跑起来。
启用认证服务器
只需使用一个注解@EnableAuthorizationServer
即可使当前服务成为一个认证服务器,starter-oauth2
已经帮我们封装好了认证服务器需要提供的4种授权模式和token
的管理。
package top.zhenganwen.securitydemo.app.config; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; /** * @author zhenganwen * @date 2019/9/11 * @desc AuthorizationServerConfig */ @Configuration @EnableAuthorizationServer public class AuthorizationServerConfig { }
现在我们可以来测试一下4中授权模式中的授权码
模式和密码
模式
首先认证服务器端要有用户,为了方便这里就不再编写DAO
和UserDetailsService
了,我们可以通过配置添加一个用户:
security.user.name=test security.user.password=test security.user.role=user # 要使用OAuth,用户需要有user角色,数据库中需存储为ROLE_USER
然后配置一个clientId/clientSecret
,这相当于别的应用调用security-demo
进行第三方登录之前需要在security-demo
的互联开发平台上申请注册的appId/appSecret
。例如现在有一个应用在security-demo
的开发平台上注册审核通过了,security-demo
会为其分配一个appId:test-client
和appSecret:123
。现在我们的security-demo
也成为了认证服务器,任何调用security-demo
API获取token
的其他应用可视为第三方应用或客户端了。
security.oauth2.client.client-id=test-client security.oauth2.client.client-secret=123
接下来我们可以对照OAuth2
的官网上的 参考文档来验证@EnableAuthorizationServer
提供的4种授权模式并获取token
测试授权码模式
参见 请求标准
授权码模式有两步:
获取授权码
观察boot启动日志,发现框架为我们添加若干接口,其中就包含了
/oauth/authorize
,这个就是授权码获取的接口。我们对照OAuth2
中获取授权码的请求标准来尝试获取授权码http://localhost/oauth/authorize? response_type=code &client_id=test-client &redirect_uri=http://example.com &scope=all
其中
response_type
固定为code
表示获取授权码,client_id
为客户端的appId
,redirect_uri
为客户端接收授权码从而进一步获取token
的回调URL(这里我们暂且随便写一个,到时候授权成功跳转到的URL上会附带授权码),scope
表示此次授权需要获取的权限范围(键值和键值的意义应由认证服务器来定,这里我们暂且随便写一个)。访问该URL后,会弹出一个basic
认证的登录框,我们输入用户名test
密码test
登录之后跳转到授权页,询问我们是否授予all
权限(实际开发中我们可以将权限按操作类型分为create
、delete
、update
、read
,也可按角色划分为user
、admin
、guest
等):我们点击同意
Approve
后点击授权Authorize
,然后跳转到回调URL并附带了授权码记下该授权码
yO4Y6q
用于后续的token
获取获取
token
我们可以通过
Chrome
插件Restlet Client
来完成此次请求- 点击
Add authorization
输入client-id
和client-secret
,工具会帮我们自动加密并附在请求头Authorizatin
中 - 填写请求参数
如果使用
Postman
则Authorization
设置如下:点击
Send
发送请求,响应如下:- 点击
密码模式
密码模式只需一步,无需授权码,可以直接获取token
使用密码模式相当于用户告诉了客户端test-client
用户在security-demo
上注册用户名密码,客户端直接拿这个去获取token
,认证服务器并不知道客户端是经用户授权同意后请求token
还是偷偷拿已知的用户名密码 来获取token
,但是如果这个客户端应用是公司内部应用,可无需担心这一点
这里还有一个细节:因为之前通过授权码模式发放了一个对应该用户的token
,所以这里再通过密码模式获取token
时返回的仍是之前生成的token
,并且过期时间expire_in
在逐渐缩短
目前没有指定
token
的存储方式,因此默认是存储在内存中的,如果你重启了服务,那么就需要重新申请token
启用资源服务器
同样的,使用一个@EnableResourceServer
注解就可以使服务成为资源服务器(在调用服务前校验token
)
package top.zhenganwen.securitydemo.app.config; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; /** * @author zhenganwen * @date 2019/9/11 * @desc ResourceServerConfig */ @Configuration @EnableResourceServer public class ResourceServerConfig { }
重启服务服务后直接访问查询用户接口/user
响应401
说明资源服务器起作用了(没有附带token
访问受保护服务会被拦截),这也不是security
默认的basic
认证在起作用,因为如果是basic
拦截它会弹出登录框,而这里并没有
然后我们使用密码模式重新生成一次token:7f6c95fd-558f-4eae-93fe-1841bd06ea5c
,并在访问接口时附带token
(添加请求头Authorization
值为token_type access_token
)
使用Postman
更加方便:
Spring Security Oauth核心源码剖析
框架核心组件如下,方框为绿色表示是具体类,为蓝色则表示是接口/抽象,括号中的类为运行时实际调用的类。下面我们将以密码模式
为例来对源码进行剖析,你也可以打断点逐步进行验证。
令牌颁发服务——TokenEndpoint
TokenEndpoint
可以看做是一个Controller
,它会受理我们申请token
的请求,见postAccessToken
方法:
@RequestMapping(value = "/oauth/token", method=RequestMethod.POST) public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException { if (!(principal instanceof Authentication)) { throw new InsufficientAuthenticationException( "There is no client authentication. Try adding an appropriate authentication filter."); } String clientId = getClientId(principal); ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId); TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient); if (clientId != null && !clientId.equals("")) { // Only validate the client details if a client authenticated during this // request. if (!clientId.equals(tokenRequest.getClientId())) { // double check to make sure that the client ID in the token request is the same as that in the // authenticated client throw new InvalidClientException("Given client ID does not match authenticated client"); } } if (authenticatedClient != null) { oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient); } if (!StringUtils.hasText(tokenRequest.getGrantType())) { throw new InvalidRequestException("Missing grant type"); } if (tokenRequest.getGrantType().equals("implicit")) { throw new InvalidGrantException("Implicit grant type not supported from token endpoint"); } if (isAuthCodeRequest(parameters)) { // The scope was requested or determined during the authorization step if (!tokenRequest.getScope().isEmpty()) { logger.debug("Clearing scope of incoming token request"); tokenRequest.setScope(Collections.<String> emptySet()); } } if (isRefreshTokenRequest(parameters)) { // A refresh token has its own default scopes, so we should ignore any added by the factory here. tokenRequest.setScope(OAuth2Utils.parseParameterList(parameters.get(OAuth2Utils.SCOPE))); } OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest); if (token == null) { throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType()); } return getResponse(token); }
首先入参包含了两个部分:principal
和parameters
,对应我们密码模式请求参数的两个部分:请求头Authorization
和请求体(grant_type
、username
、password
、scope
)。
String clientId = getClientId(principal);
principal
传入的实际上是一个UsernamePasswordToken
,对应逻辑在BasicAuthenticationFilter
的doFilterInternal
方法中:
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { final boolean debug = this.logger.isDebugEnabled(); String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Basic ")) { chain.doFilter(request, response); return; } try { String[] tokens = extractAndDecodeHeader(header, request); assert tokens.length == 2; String username = tokens[0]; if (authenticationIsRequired(username)) { UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, tokens[1]); } } catch (AuthenticationException failed) { } chain.doFilter(request, response); } private String[] extractAndDecodeHeader(String header, HttpServletRequest request) throws IOException { byte[] base64Token = header.substring(6).getBytes("UTF-8"); byte[] decoded; try { decoded = Base64.decode(base64Token); } catch (IllegalArgumentException e) { throw new BadCredentialsException( "Failed to decode basic authentication token"); } String token = new String(decoded, getCredentialsCharset(request)); int delim = token.indexOf(":"); if (delim == -1) { throw new BadCredentialsException("Invalid basic authentication token"); } return new String[] { token.substring(0, delim), token.substring(delim + 1) }; }
BasicAuthenticationFilter
会拦截/oauth/token
并尝试解析请求头Authorization
,拿到对应的Basic xxx
字符串,去掉前6个字符Basic
,获取xxx
,这实际上是我们传入的clientId
和clientSecret
使用冒号连接在一起之后再用base64
加密算法得到的,因此在extractAndDecodeHeader
方法中会对xxx
进行base64
解密得到由冒号分隔的clientId
和clientSecret
组成的密文(借用之前的clientId=test-client
和clientSecret=123
的例子,这里得到的密文就是test-client:123
),最后将client-id
作为username
、clientSecret
作为password
构建了一个UsernamePasswordToken
并返回,因此在postAccessToken
中的principal
能够得到请求头中的clientId
。
ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);
接着调用ClientDetailsService
根据clientId
查询已注册的客户端详情,即ClientDetails
,这是外部应用在注册security-demo
这个开放平台时填写并经过审核的信息,包含若干项,我们这里只有clientId
和clientSecret
两项。(authenticatedClient
表示这个client
是经我们审核过的允许接入我们开放平台的client
)
TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);
接着根据请求体参数parameters
和客户端详情clientDetails
构建了一个TokenRequest
,这个tokenRequest
表明当前这个获取token
的请求是哪个客户端(clientDetails
)要获取哪个用户(parameters.username
)的访问权限、授权模式是什么(parameters.grant_type
)、要获取哪些权限(parameters.scope
)。
if (clientId != null && !clientId.equals(""))
接着对传入的clientId
和authenticatedClient
的clientId
进行校验。也许你会问,authenticatedClient
不就是根据传入的clientId
查出来的吗,再校验岂不是多此一举。其实不然,虽然查询的方法叫做loadClientByClientId
,但是只能理解为是根据client
唯一标识查询审核过的client
,也许这个唯一标识是我们数据库中client
表的无关主键id
,也可能是clientId
字段的值。也就是说我们要从宏观上理解方法名loadClientByClientId
。因此这里对clientId
进行校验是无可厚非的。
if (authenticatedClient != null)
接着判断如果authenticatedClient
不为空则校验请求的权限范围scope
:
private void validateScope(Set<String> requestScopes, Set<String> clientScopes) { if (clientScopes != null && !clientScopes.isEmpty()) { for (String scope : requestScopes) { if (!clientScopes.contains(scope)) { throw new InvalidScopeException("Invalid scope: " + scope, clientScopes); } } } if (requestScopes.isEmpty()) { throw new InvalidScopeException("Empty scope (either the client or the user is not allowed the requested scopes)"); } }
可以联想这样一个场景:外部应用请求接入我们的开放平台以读取我们平台的用户信息,那么就对应clientScopes
为["read"]
,通过审核后该客户端请求获取token
(token
能够表明:1.你是谁;2.你能干些什么;3.访问时效)时请求参数scope
就只能为["read"]
,而不能为["read","write"]
等。这里就是校验请求token
时传入的scope
是否都包含在该客户端注册的scopes
中。
if (!StringUtils.hasText(tokenRequest.getGrantType()))
接着校验grant_type
参数不能为空,这也是oauth
协议所规定的。
if (tokenRequest.getGrantType().equals("implicit"))
接着判断传入的grant_type
是否为implicit
,也就是说客户端是否是采用简易模式
获取token
,因为简易模式
在用户同意授权后就直接获取token
了,因此不应该再调用获取token
接口。
if (isAuthCodeRequest(parameters))
接着根据请求参数判断客户端是否是采用授权码模式,如果是,就将tokenRequest
中的scope
置为空,因为客户端的权限有哪些不应该是它自己传入的scope
来决定,而是由其注册时我们审核通过的scopes
来决定,该属性后续会被从客户端详情中读取的scope
覆盖。
if (isRefreshTokenRequest(parameters))
private boolean isRefreshTokenRequest(Map<String, String> parameters) { return "refresh_token".equals(parameters.get("grant_type")) && parameters.get("refresh_token") != null; }
判断是否是刷新token
的请求。其实能够请求token
的grant_type
除了oauth
标准中的4中授权模式authorization_code
、implicit
、password
、client_credential
,还有一个refresh_token
,为了改善用户体验(传统登录方式一段时间后需要重新登陆),token
刷新机制能够在用户感知不到的情况下实现token
时效的延长。如果是刷新token
的请求,一如注释所写,refresh_token
方式也有它自己默认的scopes
,因此不应该使用请求中附带的。
OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
这才是最重要的一步,前面都是对请求参数的封装和校验。这一步会调用TokenGranter
令牌授与者生成token
,后面的getResponse(token)
就是将生成的token
直接响应了。根据传入的授权类型grant_type
及其对应的需要传入的参数,会调不同的TokenGranter
实现类进行token
的构建,这一逻辑在CompositeTokenGranter
中:
public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) { for (TokenGranter granter : tokenGranters) { OAuth2AccessToken grant = granter.grant(grantType, tokenRequest); if (grant!=null) { return grant; } } return null; }
它会依次调用4中授权模式对应TokenGranter
的实现类的grant
方法,只有和请求参数grant_type
对应的TokenGranter
会被调用,这一逻辑在AbstractTokenGranter
中:
public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) { if (!this.grantType.equals(grantType)) { return null; } String clientId = tokenRequest.getClientId(); ClientDetails client = clientDetailsService.loadClientByClientId(clientId); validateGrantType(grantType, client); logger.debug("Getting access token for: " + clientId); return getAccessToken(client, tokenRequest); }
public class AuthorizationCodeTokenGranter extends AbstractTokenGranter { private static final String GRANT_TYPE = "authorization_code"; } public class ClientCredentialsTokenGranter extends AbstractTokenGranter { private static final String GRANT_TYPE = "client_credentials"; } public class ImplicitTokenGranter extends AbstractTokenGranter { private static final String GRANT_TYPE = "implicit"; } public class ResourceOwnerPasswordTokenGranter extends AbstractTokenGranter { private static final String GRANT_TYPE = "password"; } public class RefreshTokenGranter extends AbstractTokenGranter { private static final String GRANT_TYPE = "refresh_token"; }
令牌授予者——TokenGranter
由于是以密码模式
为例,因此流程走到了ResourceOwnerPasswordTokenGranter.grant
中,它没有重写grant
方法,因此调用的是父类的grant
方法:
AbstractTokenGranter
public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) { if (!this.grantType.equals(grantType)) { return null; } String clientId = tokenRequest.getClientId(); ClientDetails client = clientDetailsService.loadClientByClientId(clientId); validateGrantType(grantType, client); return getAccessToken(client, tokenRequest); } protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) { return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest)); } protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) { return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest)); }
重点在第20
行,调用子类的getOAuth2Authentication
获取OAuth2Authentication
,并传给调用认证服务器token
服务AuthorizationServerTokenServices
生成token
。对于这里的getOAuth2Authentication
,各TokenGranter
子类又有不同的实现,因为不同授权模式的校验逻辑是不同的,例如授权码模式
这一环节需要校验请求传入的授权码(tokenRequest.parameters.code
)是否是我之前发给对应客户端(clientDetails
)的授权码;而密码模式
则是校验请求传入的用户名密码在我当前系统是否存在该用户以及密码是否正确等。在通过校验后,会返回一个OAuth2Authentication
,包含了oauth
相关信息和系统用户的相关信息。
AuthorizationServerTokenServices
OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException;
ResourceOwnerPasswordTokenGranter
@Override protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) { Map<String, String> parameters = new LinkedHashMap<String, String>(tokenRequest.getRequestParameters()); String username = parameters.get("username"); String password = parameters.get("password"); // Protect from downstream leaks of password parameters.remove("password"); Authentication userAuth = new UsernamePasswordAuthenticationToken(username, password); ((AbstractAuthenticationToken) userAuth).setDetails(parameters); try { userAuth = authenticationManager.authenticate(userAuth); } catch (AccountStatusException ase) { //covers expired, locked, disabled cases (mentioned in section 5.2, draft 31) throw new InvalidGrantException(ase.getMessage()); } catch (BadCredentialsException e) { // If the username/password are wrong the spec says we should send 400/invalid grant throw new InvalidGrantException(e.getMessage()); } if (userAuth == null || !userAuth.isAuthenticated()) { throw new InvalidGrantException("Could not authenticate user: " + username); } OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest); return new OAuth2Authentication(storedOAuth2Request, userAuth); }
可以发现,ResourceOwnerPasswordTokenGranter
的校验逻辑和我们之前所写的用户名密码认证过滤器的逻辑几乎一致:从请求中获取用户名密码,然后构建authRequest
传给ProviderManager
进行校验,ProviderManager
委托给DaoAuthenticationProvider
自然又会调用我们的UserDetailsService
自定义实现类CustomUserDetailsService
查询用户并校验。
OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);
校验通过返回认证成功的Authentication
后,会调用工厂方法根据客户端详情以及tokenRequest
构建AuthenticationServerTokenServices
所需的OAuth2Authentication
返回。
认证服务器令牌服务——AuthorizationServerTokenServices
在收到OAuth2Authentication
之后,令牌服务就能生成token
了,接着来看一下令牌服务的实现类DefaultTokenServices
是如何生成token
的:
@Transactional public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException { OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication); OAuth2RefreshToken refreshToken = null; if (existingAccessToken != null) { if (existingAccessToken.isExpired()) { if (existingAccessToken.getRefreshToken() != null) { refreshToken = existingAccessToken.getRefreshToken(); // The token store could remove the refresh token when the // access token is removed, but we want to // be sure... tokenStore.removeRefreshToken(refreshToken); } tokenStore.removeAccessToken(existingAccessToken); } else { // Re-store the access token in case the authentication has changed tokenStore.storeAccessToken(existingAccessToken, authentication); return existingAccessToken; } } // Only create a new refresh token if there wasn't an existing one // associated with an expired access token. // Clients might be holding existing refresh tokens, so we re-use it in // the case that the old access token // expired. if (refreshToken == null) { refreshToken = createRefreshToken(authentication); } // But the refresh token itself might need to be re-issued if it has // expired. else if (refreshToken instanceof ExpiringOAuth2RefreshToken) { ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken; if (System.currentTimeMillis() > expiring.getExpiration().getTime()) { refreshToken = createRefreshToken(authentication); } } OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken); tokenStore.storeAccessToken(accessToken, authentication); // In case it was modified refreshToken = accessToken.getRefreshToken(); if (refreshToken != null) { tokenStore.storeRefreshToken(refreshToken, authentication); } return accessToken; }
首先会试图从令牌仓库tokenStore
中获取token
,因为每次生成token
之后响应之前会调tokenStore
保存生成的token
,这样后续客户端拿token
访问资源的时候就有据可依。
if (existingAccessToken != null)
如果从tokenStore
获取到了token
,说明之前生成过token
,这时有两种情况:
- 旧的
token
过期了,这时要将该token
移除,如果该token
的refresh_token
还在则也要移除(请求刷新某token
时需要其对应的refresh_token
,如果token
失效了则其伴随的refresh_token
也应该不可用) - 旧的
token
没有过期,重新保存一下该token
(因为前后可能是通过不同授权模式生成token
的,对应保存的逻辑也会有差别),并直接返回该token
,方法结束。
如果没有从tokenStore
中发现旧token
,那么就新生成一个token
,保存到tokenStore
中并返回。
小结
集成用户名密码获取token
虽然框架已经帮我们封装好了认证服务器所需的4中授权模式,但是这这一般是对外的(外部应用无法读取我们系统的用户信息),用于构建开放平台。对于内部应用,我们还是需要提供用户名密码登录、手机号验证码登录等方式来获取token
。首先,框架流程一直到TokenGranter
组件这一部分我们是不能沿用了,因为已被OAuth
流程固化了。我们所能用的就是令牌生成服务AuthorizationServerTokenServices
,但它需要一个OAuth2Authentication
,而我们构建OAuth2Authentication
又需要tokenRequest
和authentication
。
我们可以在原有登录逻辑的基础之上,修改登录成功处理器,在该处理器中我们能获取到认证成功的authentication
,并且从请求头Authorization
中获取到clientId
调用注入的ClientDetailsService
查出clientDetails
并构建tokenRequest
,这样就能调用令牌生成服务来生成令牌并响应了。
在登录成功处理器中调用令牌服务
AppAuthenticationSuccessHandler
package top.zhenganwen.securitydemo.app.security.handler; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.crypto.codec.Base64; import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.oauth2.common.exceptions.UnapprovedClientAuthenticationException; import org.springframework.security.oauth2.provider.*; import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; @Component("appAuthenticationSuccessHandler") public class AppAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private ObjectMapper objectMapper; @Autowired private ClientDetailsService clientDetailsService; @Autowired private AuthorizationServerTokenServices authorizationServerTokenServices; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { // Authentication Authentication userAuthentication = authentication; // ClientDetails String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Basic ")) { throw new UnapprovedClientAuthenticationException("请求头中必须附带 oauth client 相关信息"); } String[] clientIdAndSecret = extractAndDecodeHeader(authHeader); String clientId = clientIdAndSecret[0]; String clientSecret = clientIdAndSecret[1]; ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientIdAndSecret[0]); if (clientDetails == null) { throw new UnapprovedClientAuthenticationException("无效的clientId"); } else if (!StringUtils.equals(clientSecret, clientDetails.getClientSecret())) { throw new UnapprovedClientAuthenticationException("错误的clientSecret"); } // TokenRequest TokenRequest tokenRequest = new TokenRequest(MapUtils.EMPTY_MAP, clientId, clientDetails.getScope(), "custom"); // OAuth2Request OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails); // OAuth2Authentication OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, userAuthentication); // AccessToken OAuth2AccessToken accessToken = authorizationServerTokenServices.createAccessToken(oAuth2Authentication); // response response.setContentType("application/json;charset=utf-8"); response.getWriter().write(objectMapper.writeValueAsString(accessToken)); } private String[] extractAndDecodeHeader(String header){ byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8); byte[] decoded; try { decoded = Base64.decode(base64Token); } catch (IllegalArgumentException e) { throw new BadCredentialsException( "Failed to decode basic authentication token"); } String token = new String(decoded, StandardCharsets.UTF_8); int delim = token.indexOf(":"); if (delim == -1) { throw new BadCredentialsException("Invalid basic authentication token"); } return new String[] { token.substring(0, delim), token.substring(delim + 1) }; } }
继承ResourceServerConfigurerAdapter实现Security配置
我们将BrowserSecurityConfig
中对于security
的配置拷到ResourceServerConfig
中,仅启用表单密码登录:
package top.zhenganwen.securitydemo.app.security.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.social.security.SpringSocialConfigurer; import top.zhenganwen.security.core.SecurityConstants; import top.zhenganwen.security.core.config.SmsLoginConfig; import top.zhenganwen.security.core.config.VerifyCodeValidatorConfig; import top.zhenganwen.security.core.properties.SecurityProperties; import javax.sql.DataSource; @Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Autowired private SecurityProperties securityProperties; @Autowired private AuthenticationSuccessHandler appAuthenticationSuccessHandler; @Autowired private AuthenticationFailureHandler appAuthenticationFailureHandler; @Autowired private DataSource dataSource; @Autowired private UserDetailsService customUserDetailsService; @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); return jdbcTokenRepository; } @Autowired SmsLoginConfig smsLoginConfig; @Autowired private VerifyCodeValidatorConfig verifyCodeValidatorConfig; @Autowired private SpringSocialConfigurer qqSpringSocialConfigurer; @Override protected void configure(HttpSecurity http) throws Exception { // 启用表单密码登录过滤器 http.formLogin() .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL) .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL) .successHandler(appAuthenticationSuccessHandler) .failureHandler(appAuthenticationFailureHandler); http // // 启用验证码校验过滤器 // .apply(verifyCodeValidatorConfig).and() // // 启用短信登录过滤器 // .apply(smsLoginConfig).and() // // 启用QQ登录 // .apply(qqSpringSocialConfigurer).and() // // 浏览器应用特有的配置,将登录后生成的token保存在cookie中 // .rememberMe() // .tokenRepository(persistentTokenRepository()) // .tokenValiditySeconds(3600) // .userDetailsService(customUserDetailsService) // .and() // .sessionManagement() // .invalidSessionUrl("/session-invalid.html") // .invalidSessionStrategy((request, response) -> {}) // .maximumSessions(1) // .expiredSessionStrategy(eventØ -> {}) // .maxSessionsPreventsLogin(true) // .and() // .and() // 浏览器应用特有的配置 .authorizeRequests() .antMatchers( SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL, securityProperties.getBrowser().getLoginPage(), SecurityConstants.VERIFY_CODE_SEND_URL, securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(), securityProperties.getSocial().getSignUpUrl(), securityProperties.getSocial().getSignUpProcessingUrl(), "/session-invalid.html").permitAll() .anyRequest().authenticated() .and() // 基于token的授权机制没有登录/注销的概念,只有token申请和过期的概念 .csrf().disable(); } }
如此,内部应用客户端就可以通过用户的用户名密码获取token
了:
请求头还是要附带客户端信息
请求参数传用户名密码登录所需参数即可
登录成功即获取
token
通过
token
访问服务由于
Postman
仍支持服务端写入和读取Cookie
为了避免
Session/Cookie
登录方式的影响,每次我们需要清除cookie
再发送请求。首先是不附带
token
的请求,发现请求被拦截了:然后附带
token
访问请求:
至此,用户名密码登录获取token
集成成功!
验证码和短信登录的集成流程类似,在此不再赘述。值得注意的是基于
token
的方式要摒弃对Session/Cookie
的操作,可以将要保存在服务端的信息放入如Redis
等持久层中。
集成社交登录获取token
在本节,我们将实现内部应用使用社交登录的方式向内部认证服务器获取token
。
简易模式
流程分析
如果内部应用采取的是简易模式
,用户同意授权后直接获取到外部服务提供商发放的token
,这时我们是没有办法拿这个token
去访问内部资源服务器的,需要拿这个token
去内部认证服务器换取我们系统内部通行的token
。
换取思路是,如果用户进行社交登录成功,那么内部应用就能够获取到用户的providerUserId
(在外部服务提供商中称为openId
),并且UserConnection
表应该有一条记录(userId,providerId,providerUserId
),内部应用只需将providerId
和providerUserId
传给内部认证服务器,内部认证服务器查UserConnection
表进行校验并根据userId
构建Authentication
即可生成accessToken
。
为此我们需要在内部认证服务器上写一套providerId+openId
的认证流程:
其中UserConnectionRepository
、CustomUserDetailsService
、AppAuthenticationSuccessHandler
都是现成的,可以直接拿来用。
SecurityProperties
增加处理根据openId
拿token
的URL:
package top.zhenganwen.security.core.properties; import lombok.Data; import top.zhenganwen.security.core.social.qq.connect.QQSecurityPropertie; /** * @author zhenganwen * @date 2019/9/5 * @desc SocialProperties */ @Data public class SocialProperties { private QQSecurityPropertie qq = new QQSecurityPropertie(); public static final String DEFAULT_FILTER_PROCESSING_URL = "/socialLogin"; private String filterProcessingUrl = DEFAULT_FILTER_PROCESSING_URL; public static final String DEFAULT_SIGN_UP_URL = "/sign-up.html"; private String signUpUrl = DEFAULT_SIGN_UP_URL; public static final String DEFAULT_SING_UP_PROCESSING_URL = "/user/register"; private String signUpProcessingUrl = DEFAULT_SING_UP_PROCESSING_URL; public static final String DEFAULT_OPEN_ID_FILTER_PROCESSING_URL = "/auth/openId"; private String openIdFilterProcessingUrl = DEFAULT_OPEN_ID_FILTER_PROCESSING_URL; }
自定义请求AuthenticationToken
package top.zhenganwen.securitydemo.app.security.openId; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import java.util.Collection; /** * @author zhenganwen * @date 2019/9/15 * @desc OpenIdAuthenticationToken */ public class OpenIdAuthenticationToken extends AbstractAuthenticationToken { // 作为请求认证的token时存储providerId,作为认证成功的token时存储用户信息 private final Object principal; // 作为请求认证的token时存储openId,作为认证成功的token时存储用户密码 private Object credentials; // 请求认证时调用 public OpenIdAuthenticationToken(Object providerId, Object openId) { super(null); this.principal = providerId; this.credentials = openId; setAuthenticated(false); } // 认证通过后调用 public OpenIdAuthenticationToken(Object userInfo, Object password, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = userInfo; this.credentials = password; super.setAuthenticated(true); } @Override public Object getCredentials() { return this.credentials; } @Override public Object getPrincipal() { return this.principal; } }
认证拦截器OpenIdAuthenticationFilter
package top.zhenganwen.securitydemo.app.security.openId; import org.apache.commons.lang.StringUtils; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.web.bind.ServletRequestUtils; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * @author zhenganwen * @date 2019/9/15 * @desc OpenIdAuthenticationFilter */ public class OpenIdAuthenticationFilter extends AbstractAuthenticationProcessingFilter { protected OpenIdAuthenticationFilter(String defaultFilterProcessesUrl) { super(defaultFilterProcessesUrl); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { // authRequest String providerId = ServletRequestUtils.getStringParameter(request, "providerId"); if (StringUtils.isBlank(providerId)) { throw new BadCredentialsException("providerId is required"); } String openId = ServletRequestUtils.getStringParameter(request,"openId"); if (StringUtils.isBlank(openId)) { throw new BadCredentialsException("openId is required"); } OpenIdAuthenticationToken authRequest = new OpenIdAuthenticationToken(providerId, openId); // authenticate return getAuthenticationManager().authenticate(authRequest); } }
实际认证官OpenIdAuthenticationProvider
package top.zhenganwen.securitydemo.app.security.openId; import org.hibernate.validator.internal.util.CollectionHelper; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.social.connect.UsersConnectionRepository; import org.springframework.util.CollectionUtils; import java.util.Set; /** * @author zhenganwen * @date 2019/9/15 * @desc OpenIdAuthenticationProvider */ public class OpenIdAuthenticationProvider implements AuthenticationProvider { private UsersConnectionRepository usersConnectionRepository; private UserDetailsService userDetailsService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { if (!(authentication instanceof OpenIdAuthenticationToken)) { throw new IllegalArgumentException("不支持的token认证类型:" + authentication.getClass()); } // userId OpenIdAuthenticationToken authRequest = (OpenIdAuthenticationToken) authentication; Set<String> userIds = usersConnectionRepository.findUserIdsConnectedTo(authRequest.getPrincipal().toString(), CollectionHelper.asSet(authRequest.getCredentials().toString())); if (CollectionUtils.isEmpty(userIds)) { throw new BadCredentialsException("无效的providerId和openId"); } // userDetails String useId = userIds.stream().findFirst().get(); UserDetails userDetails = userDetailsService.loadUserByUsername(useId); // authenticated authentication OpenIdAuthenticationToken authenticationToken = new OpenIdAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities()); return authenticationToken; } @Override public boolean supports(Class<?> authentication) { return OpenIdAuthenticationToken.class.isAssignableFrom(authentication); } public void setUsersConnectionRepository(UsersConnectionRepository usersConnectionRepository) { this.usersConnectionRepository = usersConnectionRepository; } public UsersConnectionRepository getUsersConnectionRepository() { return usersConnectionRepository; } public UserDetailsService getUserDetailsService() { return userDetailsService; } public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } }
OpenId认证流程配置类OpenIdAuthenticationConfig
package top.zhenganwen.securitydemo.app.security.openId; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.SecurityConfigurerAdapter; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.social.connect.UsersConnectionRepository; import org.springframework.stereotype.Component; import top.zhenganwen.security.core.properties.SecurityProperties; /** * @author zhenganwen * @date 2019/9/15 * @desc OpenIdAuthenticationConfig */ @Component public class OpenIdAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Autowired private SecurityProperties securityProperties; @Autowired private AuthenticationFailureHandler appAuthenticationFailureHandler; @Autowired private AuthenticationSuccessHandler appAuthenticationSuccessHandler; @Autowired private UsersConnectionRepository usersConnectionRepository; @Autowired private UserDetailsService customUserDetailsService; @Override public void configure(HttpSecurity builder) throws Exception { OpenIdAuthenticationFilter openIdAuthenticationFilter = new OpenIdAuthenticationFilter(securityProperties.getSocial().getOpenIdFilterProcessingUrl()); openIdAuthenticationFilter.setAuthenticationFailureHandler(appAuthenticationFailureHandler); openIdAuthenticationFilter.setAuthenticationSuccessHandler(appAuthenticationSuccessHandler); openIdAuthenticationFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class)); OpenIdAuthenticationProvider openIdAuthenticationProvider = new OpenIdAuthenticationProvider(); openIdAuthenticationProvider.setUsersConnectionRepository(usersConnectionRepository); openIdAuthenticationProvider.setUserDetailsService(customUserDetailsService); builder .authenticationProvider(openIdAuthenticationProvider) .addFilterBefore(openIdAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }
apply
应用到Security
主配置类中
package top.zhenganwen.securitydemo.app.security.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import top.zhenganwen.security.core.SecurityConstants; import top.zhenganwen.security.core.properties.SecurityProperties; import top.zhenganwen.securitydemo.app.security.openId.OpenIdAuthenticationConfig; /** * @author zhenganwen * @date 2019/9/11 * @desc ResourceServerConfig */ @Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Autowired private SecurityProperties securityProperties; @Autowired private AuthenticationSuccessHandler appAuthenticationSuccessHandler; @Autowired private AuthenticationFailureHandler appAuthenticationFailureHandler; @Autowired private OpenIdAuthenticationConfig openIdAuthenticationConfig; @Override public void configure(HttpSecurity http) throws Exception { // 启用表单密码登录获取token http.formLogin() .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL) .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL) .successHandler(appAuthenticationSuccessHandler) .failureHandler(appAuthenticationFailureHandler); // 启用社交登录获取token http.apply(openIdAuthenticationConfig); http .authorizeRequests() .antMatchers( SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL, securityProperties.getBrowser().getLoginPage(), SecurityConstants.VERIFY_CODE_SEND_URL, securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(), securityProperties.getSocial().getSignUpUrl(), securityProperties.getSocial().getSignUpProcessingUrl(), "/session-invalid.html").permitAll() .anyRequest().authenticated() .and() .csrf().disable(); } }
测试
现用Postman
模拟内部应用访问/auth/openId
请求token
:
并访问/user
测试token
有效性,访问成功!集成社交登录成功!
授权码模式
如果内部应用采用的是授权码模式,那么在外部服务提供商带着授权码回调时,内部应用直接将该回调请求转发到我们的认证服务器即可,因为我们此前已经写过社交登录模块,这样能够实现无缝衔接。
还是以我们之前实现的QQ登录为例:
内部应只需在用户同意授权,QQ认证服务器重定向到内部应用回调域时,将该回调请求原封不动转发给认证服务器即可,因为我们之前已开发过/socialLogin
接口处理社交登录。
这里测试,我们不可能真的去开发一个App,可以采用原先开发的security-browser
项目,再获取到授权码的地方打个断点,获取到授权码后停掉服务(避免后面拿授权码请求token
导致授权码失效)。然后再在Postman
中拿授权码请求token
(模拟App转发回调域到/socialLogin/qq
)
首先在security-demo
中注释security-app
而启用security-browser
<dependency> <groupId>top.zhenganwen</groupId> <artifactId>security-browser</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <!-- <dependency>--> <!-- <groupId>top.zhenganwen</groupId>--> <!-- <artifactId>security-app</artifactId>--> <!-- <version>1.0-SNAPSHOT</version>--> <!-- </dependency>-->
将CustomUserDetailsService
移至security-core
中,因为browser
和app
都有用到:
package top.zhenganwen.security.core.service; import org.hibernate.validator.constraints.NotBlank; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; 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.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.social.security.SocialUser; import org.springframework.social.security.SocialUserDetails; import org.springframework.social.security.SocialUserDetailsService; import org.springframework.stereotype.Component; import java.util.Objects; /** * @author zhenganwen * @date 2019/8/23 * @desc CustomUserDetailsService */ @Component public class CustomUserDetailsService implements UserDetailsService, SocialUserDetailsService { @Autowired BCryptPasswordEncoder passwordEncoder; private Logger logger = LoggerFactory.getLogger(getClass()); @Override public UserDetails loadUserByUsername(@NotBlank String username) throws UsernameNotFoundException { return buildUser(username); } private SocialUser buildUser(@NotBlank String username) { logger.info("登录用户名: " + username); // 实际项目中你可以调用Dao或Repository来查询用户是否存在 if (Objects.equals(username, "admin") == false) { throw new UsernameNotFoundException("用户名不存在"); } // 假设查出来的密码如下 String pwd = passwordEncoder.encode("123"); return new SocialUser( "admin", pwd, AuthorityUtils.commaSeparatedStringToAuthorityList("user,admin") ); } // 根据用户唯一标识查询用户, 你可以灵活地根据用户表主键、用户名等内容唯一的字段来查询 @Override public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException { return buildUser(userId); } }
接着设置端口80
启动服务并在如下拿授权码获取token
前设置断点(OAuth2AuthenticationService
):
访问www.zhenganwen.top/login.html
进行QQ授权登录(同时打开浏览器控制台),同意授权进行跳转,停在断点后停掉服务,在浏览器控制台中找到回调URL并复制它:
再将security-demo
的pom
切换为app
<!-- <dependency>--> <!-- <groupId>top.zhenganwen</groupId>--> <!-- <artifactId>security-browser</artifactId>--> <!-- <version>1.0-SNAPSHOT</version>--> <!-- </dependency>--> <dependency> <groupId>top.zhenganwen</groupId> <artifactId>security-app</artifactId> <version>1.0-SNAPSHOT</version> </dependency>
在Security
主配置文件中启用QQ
登录:
package top.zhenganwen.securitydemo.app.security.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import top.zhenganwen.security.core.SecurityConstants; import top.zhenganwen.security.core.properties.SecurityProperties; import top.zhenganwen.security.core.social.qq.connect.QQSpringSocialConfigurer; import top.zhenganwen.securitydemo.app.security.openId.OpenIdAuthenticationConfig; /** * @author zhenganwen * @date 2019/9/11 * @desc ResourceServerConfig */ @Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Autowired private SecurityProperties securityProperties; @Autowired private AuthenticationSuccessHandler appAuthenticationSuccessHandler; @Autowired private AuthenticationFailureHandler appAuthenticationFailureHandler; @Autowired private OpenIdAuthenticationConfig openIdAuthenticationConfig; @Autowired private QQSpringSocialConfigurer qqSpringSocialConfigurer; @Override public void configure(HttpSecurity http) throws Exception { // 启用表单密码登录获取token http.formLogin() .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL) .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL) .successHandler(appAuthenticationSuccessHandler) .failureHandler(appAuthenticationFailureHandler); // 启用社交登录获取token http.apply(openIdAuthenticationConfig); http.apply(qqSpringSocialConfigurer); http .authorizeRequests() .antMatchers( SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL, securityProperties.getBrowser().getLoginPage(), SecurityConstants.VERIFY_CODE_SEND_URL, securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(), securityProperties.getSocial().getSignUpUrl(), securityProperties.getSocial().getSignUpProcessingUrl(), "/session-invalid.html").permitAll() .anyRequest().authenticated() .and() .csrf().disable(); } }
然后我们就可以用Postman
模拟App将收到授权码回调转发给认证服务器获取token
了:
这里认证服务器在拿授权码获取token
时返回异常信息code is reused error
(授权码被重复使用),按理来说前一次我们打了断点并及时停掉了服务,该授权码没拿去请求token
过才对,这里的错误还有待排查。
处理器模式
其实就算token
获取成功,也不会响应我们想要的accessToken
,因为此前在配置SocialAuthenticationFilter
时并没有为其制定认证成功处理器,因此我们要将AppAuthenticationSuccessHandler
设置到其中,这样社交登录成功后才会生成并返回我们要向的token
。
下面我们就用简单但实用的处理器重构手法来再security-app
中为security-core
的SocialAuthenticationFilter
做一个增强:
package top.zhenganwen.security.core.social; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; /** * @author zhenganwen * @date 2019/9/15 * @desc 认证过滤器后置处理器 */ public interface AuthenticationFilterPostProcessor<T extends AbstractAuthenticationProcessingFilter> { /** * 对认证过滤器做一个增强,例如替换默认的认证成功处理器等 * @param filter */ void process(T filter); }
package top.zhenganwen.security.core.social.qq.connect; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.social.security.SocialAuthenticationFilter; import org.springframework.social.security.SpringSocialConfigurer; import top.zhenganwen.security.core.properties.SecurityProperties; import top.zhenganwen.security.core.social.AuthenticationFilterPostProcessor; /** * @author zhenganwen * @date 2019/9/5 * @desc QQSpringSocialConfigurer */ public class QQSpringSocialConfigurer extends SpringSocialConfigurer { @Autowired(required = false) // 不是必需的 private AuthenticationFilterPostProcessor<SocialAuthenticationFilter> processor; @Autowired private SecurityProperties securityProperties; @Override protected <T> T postProcess(T object) { SocialAuthenticationFilter filter = (SocialAuthenticationFilter) object; filter.setFilterProcessesUrl(securityProperties.getSocial().getFilterProcessingUrl()); filter.setSignupUrl(securityProperties.getSocial().getSignUpUrl()); processor.process(filter); return (T) filter; } }
package top.zhenganwen.securitydemo.app.security.social; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.social.security.SocialAuthenticationFilter; import org.springframework.stereotype.Component; import top.zhenganwen.security.core.social.AuthenticationFilterPostProcessor; /** * @author zhenganwen * @date 2019/9/15 * @desc SocialAuthenticationFilterProcessor */ @Component public class SocialAuthenticationFilterProcessor implements AuthenticationFilterPostProcessor<SocialAuthenticationFilter> { @Autowired private AuthenticationSuccessHandler appAuthenticationSuccessHandler; @Override public void process(SocialAuthenticationFilter filter) { filter.setAuthenticationSuccessHandler(appAuthenticationSuccessHandler); } }
集成关联社交账号功能
第三方用户信息暂存
之前,当用户第一次使用社交登录时,UserConnection
中是没有对应的关联记录的(userId->providerId-providerUserId
),当时的逻辑是将查询到的第三方用户信息放入Session
中,然后跳转到社交账号管理页面引导用户对社交账号做一个关联,后台可以通过ProviderSignInUtils
工具类从Session
中取出第三方用户信息和用户确认关联时传入的userId
做一个关联(插入到UserConnection
)中。但是Security
提供的ProviderSignInUtils
是基于Session
的,在基于token
认证机制中是行不通的。
这时我们可以将OAuth
流程走完后获取到的第三方用户信息以用户设备deviceId
作为key
缓存到Redis
中,在用户确认关联时再从Redis
中取出并和userId
作为一条记录插入UserConnection
中。其实就是换一个存储方式的过程(由内存Session
换成缓存redis
)。
对应ProviderSignInUtils
我们封装一个RedisProviderSignInUtils
将其替换就好。
引导用户关联社交账号
如下接口可以实现在所有bean
初始化完成之前都调用postProcessBeforeInitialization
,bean
初始化完毕后调用postProcessAfterInitialization
,若不想进行增强则可以返回传入的bean
,若想有针对性的增强则可根据传入的beanName
进行筛选。
public interface BeanPostProcessor { Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException; Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException; }
我们可以该接口的一个实现类SpringSocialConfigurerPostProcessor
在QQSpringSocialConfigurer bean
初始化完成后重设configure.signupUrl
,当UserConnection
没有对应Connection
关联记录时跳转到signupUrl
对应的服务。
在这个服务中应该返回一个JSON提示前端需要关联社交账号(并将之前走OAuth
获取到的第三方用户信息由ProviderSignInUtils
从Session
中取出并使用RedisProviderSignInUtils
暂存到Redis
中),而不应该向之前设置的那样跳转到社交账号关联页面。返回信息格式参考如下:
参考资料
视频教程
链接: https://pan.baidu.com/s/1wQWD4wE0CSr9gSJ2JJsT_A 提取码: z6zi