Anwen——立志成为斜杠青年:IT极客/健身/旅行/国菜大厨/中国军人

别人的看法都是狗屁,你是谁只有自己说了算。若命运不公,就和他斗到底。
——《哪吒之魔童降世》

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

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

环境准备

本文中所有实例代码已托管码云

文末有惊喜!

开发环境

  • JDK1.8
  • Maven

项目结构

image.png

  • spring-security-demo

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

  • security-core

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

  • security-browser

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

  • security-app

    移动端授权

  • security-demo

    应用security-browsersecurity-app

依赖

spring-security-demo

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

<packaging>pom</packaging>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>io.spring.platform</groupId>
            <artifactId>platform-bom</artifactId>
            <version>Brussels-SR4</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>Dalston.SR2</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>2.3.2</version>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
                <encoding>UTF-8</encoding>
            </configuration>
        </plugin>
    </plugins>
</build>

security-core

添加持久化、OAuth认证、social认证以及commons工具类等依赖,一些依赖只是先加进来以备后用

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.social</groupId>
        <artifactId>spring-social-config</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.social</groupId>
        <artifactId>spring-social-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.social</groupId>
        <artifactId>spring-social-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.social</groupId>
        <artifactId>spring-social-web</artifactId>
    </dependency>
    <dependency>
        <groupId>commons-lang</groupId>
        <artifactId>commons-lang</artifactId>
    </dependency>
    <dependency>
        <groupId>commons-collections</groupId>
        <artifactId>commons-collections</artifactId>
    </dependency>
    <dependency>
        <groupId>commons-beanutils</groupId>
        <artifactId>commons-beanutils</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.16.22</version>
        <scope>compile</scope>
    </dependency>
</dependencies>

security-browser

添加security-core和集群管理依赖

<dependencies>
    <dependency>
        <groupId>top.zhenganwen</groupId>
        <artifactId>security-core</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session</artifactId>
    </dependency>
</dependencies>

security-app

添加security-core

<dependencies>
    <dependency>
        <groupId>top.zhenganwen</groupId>
        <artifactId>security-core</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>

security-demo

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

<artifactId>security-demo</artifactId>
<dependencies>
    <dependency>
        <groupId>top.zhenganwen</groupId>
        <artifactId>security-browser</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>

配置

security-demo中添加启动类如下

package top.zhenganwen.securitydemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author zhenganwen
 * @date 2019/8/18
 * @desc SecurityDemoApplication
 */
@SpringBootApplication
@RestController
public class SecurityDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(SecurityDemoApplication.class, args);
    }

    @RequestMapping("/hello")
    public String hello() {
        return "hello spring security";
    }
}

根据报错信息添加mysql连接信息

spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/test?useUnicode=yes&characterEncoding=UTF-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=123456

暂时用不到session集群共享和redis,先禁用掉

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

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

security.basic.enabled = false

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

Restful

Restful VS 传统

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

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

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

SpringMVC高级特性与REST服务

Jar包方式运行

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

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

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <version>1.3.3.RELEASE</version>
            <executions>
                <execution>
                    <goals>
                        <goal>repackage</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
    <!-- 生成的jar文件名 -->
    <finalName>demo</finalName>
</build>

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

使用MockMVC编写接口测试用例

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

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

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
</dependency>

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

package top.zhenganwen.securitydemo;

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

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

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

    @Autowired
    WebApplicationContext webApplicationContext;

    private MockMvc mockMvc;

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

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

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

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

其中比较重要的API为MockMvcMockMvcRequestBuildersMockMvcRequestBuilders

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

MVC注解细节

@RestController

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

@RequestMapping

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

package top.zhenganwen.securitydemo.dto;

import lombok.Data;

import java.io.Serializable;

/**
 * @author zhenganwen
 * @date 2019/8/18
 * @desc User
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {

    private String username;
    private String password;
}

package top.zhenganwen.securitydemo.web.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import top.zhenganwen.securitydemo.dto.User;

import java.util.Arrays;
import java.util.List;

/**
 * @author zhenganwen
 * @date 2019/8/18
 * @desc UserController
 */
@RestController
public class UserController {

    @GetMapping("/user")
    public List<User> query(String username) {
        System.out.println(username);
        List<User> users = Arrays.asList(new User(), new User(), new User());
        return users;
    }
}
package top.zhenganwen.securitydemo.web.controller;

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

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

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

    @Autowired
    private WebApplicationContext webApplicationContext;

    private MockMvc mockMvc;

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

    @Test
    public void query() throws Exception {
        mockMvc.perform(get("/user").
                contentType(MediaType.APPLICATION_JSON_UTF8)
                .param("username", "tom"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(3));
    }
}

通过MockMvcRequestBuilders.param可以为请求附带URL形式参数。

指定提交方式

如果没有通过method属性指定提交方式,那么所有的提交方式都会被受理,但如果设置@RequestMapping(method = RequestMethod.GET),那么只有GET请求会被受理,其他提交方式都会导致405 unsupported request method

@RequestParam

必填参数

上例代码,如果请求不附带参数username,那么Controller的参数就会被赋予数据类型默认值。如果你想请求必须携带该参数,否则不予处理,那么就可以使用@RequestParam并指定required=true(不指定也可以,默认就是)

Controller

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

ControllerTest

@Test
public void testBadRequest() throws Exception {
    mockMvc.perform(get("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().is4xxClientError());
}

因为请求没有附带参数username,所以会报错400 bad request,我们可以使用is4xxClientError()对响应状态码为400的请求进行断言

参数名映射

SpringMVC默认是按参数名相同这一规则映射参数值得,如果你想将请求中参数username的值绑定到方法参数userName上,可以通过name属性或value属性

@GetMapping("/user")
public List<User> query(@RequestParam(name = "username") String userName) {
    System.out.println(userName);
    List<User> users = Arrays.asList(new User(), new User(), new User());
    return users;
}

@GetMapping("/user")
public List<User> query(@RequestParam("username") String userName) {
    System.out.println(userName);
    List<User> users = Arrays.asList(new User(), new User(), new User());
    return users;
}
@Test
public void testParamBind() throws Exception {
    mockMvc.perform(get("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .param("username", "tom"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.length()").value(3));
}

默认参数值

如果希望不强制请求携带某参数,但又希望方法参数在没有接收到参数值时能有个默认值(例如“”null更不容易报错),那么可以通过defaultValue属性

@GetMapping("/user")
public List<User> query(@RequestParam(required = false,defaultValue = "") String userName) {
    Objects.requireNonNull(userName);
    List<User> users = Arrays.asList(new User(), new User(), new User());
    return users;
}
@Test
public void testDefaultValue() throws Exception {
    mockMvc.perform(get("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.length()").value(3));
}

Bean绑定

如果请求附带的参数较多,并且各参数都隶属于某个对象的属性,那么将它们一一写在方法参列比较冗余,我们可以将它们统一封装到一个数据传输对象(Data Transportation Object DTO)中,如

package top.zhenganwen.securitydemo.dto;

import lombok.Data;

/**
 * @author zhenganwen
 * @date 2019/8/19
 * @desc UserCondition
 */
@Data
public class UserQueryConditionDto {

    private String username;
    private String password;
    private String phone;
}

然后在方法入参填写该对象即可,SpringMVC会帮我们实现请求参数到对象属性的绑定(默认绑定规则是参数名一致)

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

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

@Test
public void testDtoBind() throws Exception {
    mockMvc.perform(get("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .param("username", "tom")
                    .param("password", "123456")
                    .param("phone", "12345678911"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.length()").value(3));
}

Bean绑定不影响@RequestParam绑定

并且不用担心会和@RequestParam冲突,输出如下

tom
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[
  username=tom
  password=123456
  phone=12345678911
]

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

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

null
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[
  username=tom
  password=123456
  phone=12345678911
]

分页参数绑定

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

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

@PathVariable

变量占位

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

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

1

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

正则匹配

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

@GetMapping("/user/{id:\\d+}")
public User getInfo(@PathVariable("id") Long id) {
    System.out.println(id);
    return new User("jack","123");
}
@Test
public void testRegExSuccess() throws Exception {
    mockMvc.perform(get("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().isOk());
}

@Test
public void testRegExFail() throws Exception {
    mockMvc.perform(get("/user/abc").
                    contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(status().is4xxClientError());
}

@JsonView

应用场景

有时我们需要对响应对象的某些字段进行过滤,例如查询所有用户时不显示password字段,根据id查询用户时则显示password字段,这时可以通过@JsonView注解实现此类功能

使用方法

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)

日期类型参数处理

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

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

@PostMapping
public void createUser(@RequestBody User user) {
    System.out.println(user);
}
@Test
public void testDateBind() throws Exception {
    Date date = new Date();
    System.out.println(date.getTime());
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

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

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

抽离校验逻辑

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

@PostMapping
public void createUser(@RequestBody User user) {
    if (StringUtils.isBlank(user.getUsername())) {
        throw new IllegalArgumentException("用户名不能为空");
    }
    if (StringUtils.isBlank(user.getPassword())) {
        throw new IllegalArgumentException("密码不能为空");
    }
    System.out.println(user);
}

但是如果其他地方也需要校验就需要编写重复的代码,一旦校验逻辑发生改变就需要改变多处,并且如果有所遗漏还会给程序埋下隐患。有点重构意识的可能会将每个校验逻辑单独封装一个方法,但仍显冗余。

SpringMVC Restful则推荐使用@Valid来实现参数的校验,并且未通过校验的会响应400 bad request给前端,以状态码表示处理结果(及请求格式不对),而不是像上述代码一样直接抛异常导致前端收到的状态码是500

首先我们要使用hibernate-validator校验框架提供的一些约束注解来约束Bean字段

@NotBlank
@JsonView(UserBasicView.class)
private String username;

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

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

@PostMapping
public void createUser(@RequestBody User user) {
    System.out.println(user);
}
@Test
public void testConstraintValidateFail() throws Exception {
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"\"}"))
        .andExpect(status().isOk());
}

User(id=null, username=, password=null, birthday=null)

我们还要在需要校验的Bean前添加@Valid注解,这样SpringMVC会根据我们在该Bean中添加的约束注解进行校验,在校验不通过时响应400 bad request

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

约束注解

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

image.png

image.png

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

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

@Past
private Date birthday;

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

@PostMapping
public void createUser(@Valid @RequestBody User user) {
    System.out.println(user);
}
@Test
public void testValidatePastTimeSuccess() throws Exception {
    // 获取一年前的时间点
    Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

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

复用校验逻辑

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

@PutMapping("/{id}")
public void update(@Valid @RequestBody User user, @PathVariable Long id) {
    System.out.println(user);
    System.out.println(id);
}
@Test
public void testUpdateSuccess() throws Exception {
    mockMvc.perform(put("/user/1").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"password\":\"789\"}"))
        .andExpect(status().isOk());
}

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

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

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

BindingResult处理校验结果

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

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

@PostMapping
public void createUser(@Valid @RequestBody User user,BindingResult errors) {
    if (errors.hasErrors()) {
        errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
    }
    System.out.println(user);
}

@PutMapping("/{id}")
public void update(@PathVariable Long id,@Valid @RequestBody User user, BindingResult errors) {
    if (errors.hasErrors()) {
        errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
    }
    System.out.println(user);
    System.out.println(id);
}
@Test
public void testBindingResult() throws Exception {
    Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    mockMvc.perform(post("/user").
                    contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content("{\"username\":\"jack\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
        .andExpect(status().isOk());
}

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

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

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

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

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

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

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

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

校验

自定义消息

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

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

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

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

@NotBlank(message = "用户名不能为空")
@JsonView(UserBasicView.class)
private String username;

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

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

自定义校验注解

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

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

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 {
    
}

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

String message() default "{org.hibernate.validator.constraints.NotBlank.message}";

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

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

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

@Target({FIELD})
@Retention(RUNTIME)
public @interface Unrepeatable {
    String message() default "用户名已被注册";

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

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

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会返回如下错误页面

image.png

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

{
    "timestamp": 1566268880358,
    "status": 404,
    "error": "Not Found",
    "message": "No message available",
    "path": "/xxx"
}

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

@RequestMapping(produces = "text/html")
public ModelAndView errorHtml(HttpServletRequest request,
                              HttpServletResponse response) {
    HttpStatus status = getStatus(request);
    Map<String, Object> model = Collections.unmodifiableMap(getErrorAttributes(
        request, isIncludeStackTrace(request, MediaType.TEXT_HTML)));
    response.setStatus(status.value());
    ModelAndView modelAndView = resolveErrorView(request, response, status, model);
    return (modelAndView == null ? new ModelAndView("error", model) : modelAndView);
}

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

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

自定义错误页面

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

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

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

模拟处理请求时发生异常

@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
    throw new RuntimeException("id不存在");
    //        System.out.println(id);
    //        return new User(1L, "jack", "123");
    //        return null;
}

访问localhost:8080/xxx显示404.html页面,访问localhost:8080/user/1显示500.html页面

值得注意的是,自定义异常页面并不会导致非浏览器请求也会响应该页面

自定义异常处理

对于4XX的客户端错误,SpringMVC会直接返回错误响应和不会执行Controller方法;对于500的服务端抛出异常,则会收集异常类的message字段值返回

默认异常响应结果

例如客户端错误,GET /user/1

{
    "timestamp": 1566270327128,
    "status": 500,
    "error": "Internal Server Error",
    "exception": "java.lang.RuntimeException",
    "message": "id不存在",
    "path": "/user/1"
}

例如服务端错误

@PostMapping
public void createUser(@Valid @RequestBody User user) {
    System.out.println(user);
}
POST	localhost:8080/user
Body	{}
{
    "timestamp": 1566272056042,
    "status": 400,
    "error": "Bad Request",
    "exception": "org.springframework.web.bind.MethodArgumentNotValidException",
    "errors": [
        {
            "codes": [
                "NotBlank.user.username",
                "NotBlank.username",
                "NotBlank.java.lang.String",
                "NotBlank"
            ],
            "arguments": [
                {
                    "codes": [
                        "user.username",
                        "username"
                    ],
                    "arguments": null,
                    "defaultMessage": "username",
                    "code": "username"
                }
            ],
            "defaultMessage": "用户名不能为空",
            "objectName": "user",
            "field": "username",
            "rejectedValue": null,
            "bindingFailure": false,
            "code": "NotBlank"
        },
        {
            "codes": [
                "NotBlank.user.password",
                "NotBlank.password",
                "NotBlank.java.lang.String",
                "NotBlank"
            ],
            "arguments": [
                {
                    "codes": [
                        "user.password",
                        "password"
                    ],
                    "arguments": null,
                    "defaultMessage": "password",
                    "code": "password"
                }
            ],
            "defaultMessage": "密码不能为空",
            "objectName": "user",
            "field": "password",
            "rejectedValue": null,
            "bindingFailure": false,
            "code": "NotBlank"
        }
    ],
    "message": "Validation failed for object='user'. Error count: 2",
    "path": "/user"
}

自定义异常响应结果

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

package top.zhenganwen.securitydemo.web.exception.response;

import lombok.Data;

import java.io.Serializable;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc IdNotExistException
 */
@Data
public class IdNotExistException extends RuntimeException {

    private Serializable id;

    public IdNotExistException(Serializable id) {
        super("id不存在");
        this.id = id;
    }
}
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
    throw new IdNotExistException(id);
}

GET /user/1

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

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

  1. 自定义的异常处理类需要添加@ControllerAdvice
  2. 在处理异常的方法上使用@ExceptionHandler声明该方法要截获哪些异常,所有的Controller若抛出这些异常中的一个则会转为执行该方法
  3. 捕获到的异常会作为方法的入参
  4. 方法返回的结果与Controller方法返回的结果意义相同,如果需要返回json则需在方法上添加@ResponseBody注解,如果在类上添加该注解则表示每个方法都有该注解
package top.zhenganwen.securitydemo.web.exception.handler;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import top.zhenganwen.securitydemo.web.exception.response.IdNotExistException;

import java.util.HashMap;
import java.util.Map;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc UserControllerExceptionHandler
 */
@ControllerAdvice
@ResponseBody
public class UserControllerExceptionHandler {

    @ExceptionHandler(IdNotExistException.class)
    public Map<String, Object> handleIdNotExistException(IdNotExistException e) {
        Map<String, Object> jsonResult = new HashMap<>();
        jsonResult.put("message", e.getMessage());
        jsonResult.put("id", e.getId());
        return jsonResult;
    }
}

重启后使用Postman GET /user/1得到响应如下

{
    "id": 1,
    "message": "id不存在"
}

拦截

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

过滤器Filter

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

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

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

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

@Bean
public FilterRegistrationBean registerTimeFilter() {
    FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
    filterRegistrationBean.setFilter(timeFilter);
    filterRegistrationBean.addUrlPatterns("/*");
    return filterRegistrationBean;
}

拦截器Interceptor

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

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

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

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

方法调用时序图大致如下

image.png

切片Aspect

应用场景

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

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

if (!mappedHandler.applyPreHandle(processedRequest, response)) {
    return;
}

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

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

使用方法

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

SpringMVC中使用AOP我们需要三步

  • 编写切片/切面类,将切入点和增强结合在一起
    • 添加@Component,注入Spring容器
    • 添加@Aspect,启动切面编程开关
  • 编写切入点,使用注解可以完成,切入点包含两部分:哪些方法需要增强以及增强的时机
    • 切入时机
      • @Before,方法执行前
      • @AfterReturning,方法正常执行结束后
      • @AfterThrowing,方法抛出异常后
      • @After,方法正常执行结束return前,相当于在return前插入了一段finally
      • @Around,可利用注入的入参ProceedingJoinPoint灵活的实现上述4种时机,它的作用与拦截器方法中的handler类似,只不过提供了更多有用的运行时信息
    • 切入点,可以使用execution表达式,具体详见:https://docs.spring.io/spring/docs/4.3.25.RELEASE/spring-framework-reference/htmlsingle/#aop-pointcuts-examples
  • 编写增强方法,
    • 其中只有@Around可以有入参,能拿到ProceedingJoinPoint实例
    • 通过调用ProceedingJoinPointpoint.proceed()能够调用对应的Controller方法并拿到返回值
package top.zhenganwen.securitydemo.web.aspect;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;

/**
 * @author zhenganwen
 * @date 2019/8/20
 * @desc GlobalControllerAspect
 */
@Aspect
@Component
public class GlobalControllerAspect {

    // top.zhenganwen.securitydemo.web.controller包下的所有Controller的所有方法
    @Around("execution(* top.zhenganwen.securitydemo.web.controller.*.*(..))")
    public Object handleControllerMethod(ProceedingJoinPoint point) throws Throwable {

        // handler对应的方法签名(哪个类的哪个方法,参数列表是什么)
        String service = "【"+point.getSignature().toLongString()+"】";
        // 传入handler的参数值
        Object[] args = point.getArgs();

        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
        Date start = new Date();
        System.out.println("[GlobalControllerAspect]开始调用服务" + service + " 请求参数: " + Arrays.toString(args) + ", " + simpleDateFormat.format(start));

        Object result = null;
        try {
            // 调用实际的handler并取得结果
            result = point.proceed();
        } catch (Throwable throwable) {
            System.out.println("[GlobalControllerAspect]调用服务" + service + "发生异常, message=" + throwable.getMessage());
            throw throwable;
        }

        Date end = new Date();
        System.out.println("[GlobalControllerAspect]服务" + service + "调用结束,响应结果为: " + result+", "+simpleDateFormat.format(end)+", 共耗时: "+(end.getTime()-start.getTime())+
                "ms");

        // 返回响应结果,不一定要和handler的处理结果一致
        return result;
    }
}

测试

@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
    System.out.println("[UserController # getInfo]query user by id");
    return new User();
}

GET /user/1

[TimeFilter] 收到服务调用:【GET /user/1】
[TimeFilter] 开始执行服务【GET /user/1】2019-08-20 05:21:48
[TimeInterceptor # preHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】被调用 2019-08-20 05:21:48
[GlobalControllerAspect]开始调用服务【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】 请求参数: [1], 2019-08-20 05:21:48
[UserController # getInfo]query user by id
[GlobalControllerAspect]服务【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】调用结束,响应结果为: User(id=null, username=null, password=null, birthday=null), 2019-08-20 05:21:48, 共耗时: 0ms
[TimeInterceptor # postHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】调用结束 2019-08-20 05:21:48 共耗时:4ms
[TimeInterceptor # afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】调用结束 2019-08-20 05:21:48 共耗时:4ms
[TimeFilter] 服务【GET /user/1】执行完毕 2019-08-20 05:21:48,共耗时:6ms
[TimeFilter] 收到服务调用:【GET /user/1】
[TimeFilter] 开始执行服务【GET /user/1】2019-08-20 05:24:40
[TimeInterceptor # preHandle] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】被调用 2019-08-20 05:24:40
[GlobalControllerAspect]开始调用服务【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】 请求参数: [1], 2019-08-20 05:24:40
[UserController # getInfo]query user by id
[GlobalControllerAspect]调用服务【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】发生异常, message=id not exist
[TimeInterceptor # afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】调用结束 2019-08-20 05:24:40 共耗时:2ms
[TimeInterceptor#afterCompletion] 服务【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】调用异常:id not exist

java.lang.RuntimeException: id not exist
	at top.zhenganwen.securitydemo.web.controller.UserController.getInfo(UserController.java:42)
    ...
 
[TimeInterceptor # preHandle] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】被调用 2019-08-20 05:24:40
[TimeInterceptor # postHandle] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】调用结束 2019-08-20 05:24:40 共耗时:2ms
[TimeInterceptor # afterCompletion] 服务【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】调用结束 2019-08-20 05:24:40 共耗时:3ms

总结

请求过程

image.png

响应过程

image.png

文件上传下载及Mock测试

文件上传

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

	@Test
    public void upload() throws Exception {
        File file = new File("C:\\Users\\zhenganwen\\Desktop", "hello.txt");
        FileInputStream fis = new FileInputStream(file);
        byte[] content = new byte[fis.available()];
        fis.read(content);
        String fileKey = mockMvc.perform(fileUpload("/file")
                /**
                 * name         请求参数,相当于<input>标签的的`name`属性
                 * originalName 上传的文件名称
                 * contentType  上传文件需指定为`multipart/form-data`
                 * content      字节数组,上传文件的内容
                 */
                .file(new MockMultipartFile("file", "hello.txt", "multipart/form-data", content)))
                .andExpect(status().isOk())
                .andReturn().getResponse().getContentAsString();
        System.out.println(fileKey);
    }

文件管理Controller

package top.zhenganwen.securitydemo.web.controller;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.Date;

/**
 * @author zhenganwen
 * @date 2019/8/21
 * @desc FileController
 */
@RestController
@RequestMapping("/file")
public class FileController {

    public static final String FILE_STORE_FOLDER = "C:\\Users\\zhenganwen\\Desktop\\";

    @PostMapping
    public String upload(MultipartFile file) throws IOException {

        System.out.println("[FileController]文件请求参数: " + file.getName());
        System.out.println("[FileController]文件名称: " + file.getName());
        System.out.println("[FileController]文件大小: "+file.getSize()+"字节");

        
        String fileKey = new Date().getTime() + "_" + file.getOriginalFilename();
        File storeFile = new File(FILE_STORE_FOLDER, fileKey);

        // 可以通过file.getInputStream将文件上传到FastDFS、云OSS等存储系统中
//        InputStream inputStream = file.getInputStream();
//        byte[] content = new byte[inputStream.available()];
//        inputStream.read(content);

        file.transferTo(storeFile);

        return fileKey;
    }
}

测试结果

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

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

文件下载

引入apache io工具包

<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.5</version>
</dependency>

文件下载接口

@GetMapping("/{fileKey:.+}")
public void download(@PathVariable String fileKey, HttpServletResponse response) throws IOException {

    try (
        InputStream is = new FileInputStream(new File(FILE_STORE_FOLDER, fileKey));
        OutputStream os = response.getOutputStream()
    ) {
        // 下载需要设置响应头为 application/x-download
        response.setContentType("application/x-download");
        // 设置下载询问框中的文件名
        response.setHeader("Content-Disposition", "attachment;filename=" + fileKey);

        IOUtils.copy(is, os);
        os.flush();
    }
}

测试:浏览器访问http://localhost:8080/file/1566349460611_hello.txt

映射写成/{fileKey:.+}而不是/{fileKey}的原因是SpringMVC会忽略映射中.符号之后的字符。正则.+表示匹配任意个非\n的字符,不加该正则的话,方法入参fileKey获取到的值将是1566349460611_hello而不是1566349460611_hello.txt

异步处理REST服务

我们之前都是客户端每发送一个请求,tomcat线程池就派一个线程进行处理,直到请求处理完成响应结果,该线程都是被占用的。一旦系统并发量上来了,那么tomcat线程池会显得分身乏力,这时我们可以采取异步处理的方式。

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

//@Component
public class TimeFilter implements Filter {

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

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

Callable异步处理

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

package top.zhenganwen.securitydemo.web.controller;

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

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


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

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

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

使用Postman测试结果如下

image.png

控制台日志:

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

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

DeferredResult异步处理

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

image.png

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

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

image.png

秒杀系统AsyncOrderController

package top.zhenganwen.securitydemo.web.async;

import org.apache.commons.lang.RandomStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;

import java.util.concurrent.TimeUnit;


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

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

    @Autowired
    private DeferredResultHolder deferredResultHolder;

    @Autowired
    private OrderProcessingQueue orderProcessingQueue;

    // 秒杀系统下单请求
    @PostMapping
    public DeferredResult<String> createOrder() {

        logger.info("【请求线程】收到下单请求");

        // 生成12位单号
        String orderNumber = RandomStringUtils.randomNumeric(12);

        // 创建处理结果凭证放入缓存,以便监听(订单系统向MQ发送的订单处理结果消息的)线程向凭证中设置结果,这会触发该结果响应给客户端
        DeferredResult<String> deferredResult = new DeferredResult<>();
        deferredResultHolder.placeOrder(orderNumber, deferredResult);

        // 异步向MQ发送下单消息,假设需要200ms
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500);
                synchronized (orderProcessingQueue) {
                    while (orderProcessingQueue.size() >= Integer.MAX_VALUE) {
                        try {
                            orderProcessingQueue.wait();
                        } catch (Exception e) {
                        }
                    }
                    orderProcessingQueue.addLast(orderNumber);
                    orderProcessingQueue.notifyAll();
                }
                logger.info("向MQ发送下单消息, 单号: {}", orderNumber);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }, "本地临时线程-向MQ发送下单消息")
        .start();

        logger.info("【请求线程】继续处理其它请求");

        // 并不会立即将deferredResult序列化成JSON并返回给客户端,而会等deferredResult的setResult被调用后,将传入的result转成JSON返回
        return deferredResult;
    }
}

两个MQ

package top.zhenganwen.securitydemo.web.async;

import org.springframework.stereotype.Component;

import java.util.LinkedList;

/**
 * @author zhenganwen
 * @date 2019/8/22
 * @desc OrderProcessingQueue   下单消息MQ
 */
@Component
public class OrderProcessingQueue extends LinkedList<String> {
}
package top.zhenganwen.securitydemo.web.async;

import org.springframework.stereotype.Component;

import java.util.LinkedList;

/**
 * @author zhenganwen
 * @date 2019/8/22
 * @desc OrderCompletionQueue   订单处理完成MQ
 */
@Component
public class OrderCompletionQueue extends LinkedList<OrderCompletionResult> {
}
package top.zhenganwen.securitydemo.web.async;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @author zhenganwen
 * @date 2019/8/22
 * @desc OrderCompletionResult  订单处理完成结果信息,包括单号和是否成功
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderCompletionResult {
    private String orderNumber;
    private String result;
}

凭证缓存

package top.zhenganwen.securitydemo.web.async;

import org.hibernate.validator.constraints.NotBlank;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.async.DeferredResult;

import javax.validation.constraints.NotNull;
import java.util.HashMap;
import java.util.Map;

/**
 * @author zhenganwen
 * @date 2019/8/22
 * @desc DeferredResultHolder   订单处理结果凭证缓存,通过凭证可以在未来的时间点获取处理结果
 */
@Component
public class DeferredResultHolder {

    private Map<String, DeferredResult<String>> holder = new HashMap<>();

    // 将订单处理结果凭证放入缓存
    public void placeOrder(@NotBlank String orderNumber, @NotNull DeferredResult<String> result) {
        holder.put(orderNumber, result);
    }

    // 向凭证中设置订单处理完成结果
    public void completeOrder(@NotBlank String orderNumber, String result) {
        if (!holder.containsKey(orderNumber)) {
            throw new IllegalArgumentException("orderNumber not exist");
        }
        DeferredResult<String> deferredResult = holder.get(orderNumber);
        deferredResult.setResult(result);
    }
}

两个队列对应的两个监听

package top.zhenganwen.securitydemo.web.async;

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

import java.util.concurrent.TimeUnit;

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

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

    @Autowired
    OrderProcessingQueue orderProcessingQueue;

    @Autowired
    OrderCompletionQueue orderCompletionQueue;

    @Autowired
    DeferredResultHolder deferredResultHolder;

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

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

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

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


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

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

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

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

测试

image.png

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

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

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

image.png

registerCallableInterceptors & registerDeferredResultInterceptors

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

setDefaultTimeout

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

setTaskExecutor

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

前后端分离

Swagger接口文档

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

依赖

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>2.7.0</version>
</dependency>
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    <version>2.7.0</version>
</dependency>

在启动类SecurityDemoApplication上添加@@EnableSwagger2注解开启接口文档自动生成开关,启动后访问localhost:8080/swagger-ui.html

常用注解

  • @ApiOperation,注解在Controller方法上,用来描述方法的行为

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

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

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

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

image.png

image.png

WireMock

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

在后端接口没开发完成时,前端可能会通过本地文件的方式伪造一些静态数据(例如JSON文件)作为请求的响应结果,这种方式在前端只有一种终端时是没问题的。但是当前端有多种,如PC、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#getFileFileUtils#readLines将数据读成字符串

访问localhost:8062/order/1

{
    id: 1,
    orderNumber: "545616156"
}

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

使用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,验证码生成如下:

image.png

接下来我们编写验证码校验逻辑,由于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返回了一个exceptionJSON串

这里有两点需要优化

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

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

    //        response.getWriter().write(objectMapper.writeValueAsString(exception));
    response.getWriter().write(objectMapper.writeValueAsString(new SimpleResponseResult(exception.getMessage())));
    

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

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

重新测试

{
    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"
}

重构图形验证码功能

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

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

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

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

image.png

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-demoapplication.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会在所有未标注@ConditionOnMissingBeanbean都被实例化注入到容器中后,判断容器中是否存在id为imageCodeGeneratorbean,如果不存在才会进行实例化并作为id为imageCodeGeneratorbean被使用

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调用图形验证码生成器接口生成图形验证码时如果走的是我们这个自定义的图形验证码生成器就会抛出异常。注意@Componentvalue属性要和@ConditionOnMissingBeanname属性一致才能实现替换

实现记住我功能

需求

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

securityrememberMe.gif

实现

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

  1. 首先页面需要一个“记住我”选框,选框的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>
    
  2. 在数据源对应的数据库中创建一张表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)
    
  3. 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();
        }
    
  4. 测试

    未登录访问/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重命名为VerifyCodeImageCode继承以复用代码

@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,重构验证码生成器类结构:

image.png

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中的两个方法imageCodesmsCode的主干流程是一致的:

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

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

image.png

image.png

常量类

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="">
    

测试通过,重构成功!

短信验证码登录

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

image.png

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

AuthenticationManager会遍历所有的AuthenticationProvider找到其中支持认证该Authentication并调用authenticate进行实际的认证,因此我们需要实现自己的Authentication(SmsAuthenticationToken)和认证该AuthenticationAuthenticationProviderSmsAuthenticationProvider),并将SmsAuthenticationProvider添加到SpringSecurtyAuthenticationProvider集合中,以使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之后的代码都未执行,也就是说我们的CustomUserDetailsServiceBCryptPasswordEncoder都没有注入到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-browsersecurity-app中只留自身特有的配置(例如将token写到cookie中的remember-me方式应该放在security-browser中,而security-app中对应放移动端remember-me的配置方式),最后security-browsersecurity-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/**";
}

验证码过滤器重构

image.png

  • 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);

系统配置重构

image.png

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,第三方应用,需要依赖具有强大用户基础的服务提供商进行引流的应用

image.png

上述第二步还涉及到几种授权模式:

  • 授权码模式(authorization code)
  • 密码模式(resource owner password credentials)
  • 客户卡模式(client credentials)
  • 简化模式(implicit)

本章和下一章(app)将分别详细介绍前两种模式,现在互联网上几乎大部分社交平台如QQ、微博、淘宝等服务提供商都是采用的授权码模式

授权码模式授权流程

以我们平常访问某社交网站时不想注册该网站用户而直接使用QQ登录这一场景为例,如图是该社交网站作为第三方应用使用OAuth协议开发QQ联合登录的大致时序图

image.png

授权码模式之所以被广泛使用,其原因有如下两点:

  • 用户同意授权这一行为是在认证服务器上进行确认的,相比较其他3种模式在第三方应用客户端上确认(客户端可伪造用户同意授权)而言,更加透明
  • 认证服务器不是直接返回token,而是先返回授权码。像有的静态网站可能会使用implicit模式让认证服务器直接返回token从而再在页面上使用AJAX调用资源服务器接口。前者是认证服务器对接第三方应用服务器(认证服务器返回token是通过回调与第三方应用事先约定好的第三方应用接口并传入token,因此所有token都是存放在服务端的);而后者是认证服务器对接浏览器等第三方应用的客户端,token直接传给客户端存在安全风险

这也是为什么现在主流的服务提供商都采用授权码模式,因为其授权流程更完备、更安全。

Spring Social基本原理

Spring Social其实就是将上述时序图所描述的授权流程封装到了特定的类和接口中了。OAuth协议有两个版本,国外很早就用了所以流行OAuth1,而国内用得比较晚因此基本都是OAuth2,本章也是基于OAuth2来集成QQ、微信登录功能。

image.png

如图是Spring Social的主要组件,各功能如下:

  • OAuth2Operations,封装从请求用户授权到认证服务向我们返回token的整个流程。OAuth2Template是为我们提供的默认实现,这个流程基本上是固定的,无需我们介入
  • Api,封装拿到token后我们调用资源服务器接口获取用户信息的过程,这个需要我们自己定义,毕竟框架也不知道我们要接入哪个开放平台,但它也为我们提供了一个抽象AbstractOAuth2ApiBinding
  • AbstractOAuth2ServiceProvider,集成OAuth2OperationApi,串起获取token和拿token访问用户资源两个过程
  • Connection,统一用户视图,由于各服务提供商返回的用户信息数据结构是不一致的,我们需要通过适配器ApiAdapter将其统一适配到Connection这个数据结构上,可以看做用户在服务提供商中的实体
  • OAuth2ConnectionFactory,集成AbstractOAuth2ServiceProviderApiAdapter,完成整个用户授权以及获取用户信息实体的流程
  • UsersConnectionRepository,我们的系统中一般都有自己的用户表,如何将接入系统的用户实体Connection和我们自己的用户实体User进行对应就靠它来完成,用来完成我们userIdConnection的映射

开发QQ登录功能

准备工作:申请appId和appSecret,详见准备工作_oauth2-0

回调域:http://www.zhenganwen.top/socialLogin/qq

要开发一个第三方接入功能其实就是对上图一套组件逐个进行实现一下,本节我们将开发QQ登录功能,首先从上图的左半部分开始实现。

ServiceProvider

Api,声明一个对应OpenAPI的方法,用来调用该API并将响应结果转成POJO返回,对应授权码模式时序图中的第7步

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,集成OAuth2OperationsApi,使用前者来完成授权获取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,再根据providerIdproviderUserIdUserConnection表中查询对应的userId,最后根据userId到用户表中查询用户

image.png

因此我们还需要启用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

由于每个系统申请的appIdappSecret都不同,所以我们将其抽取到了配置文件中

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-idqq

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登录后响应如下

image.png

提示我们回调地址是非法的,我们可以看一下地址栏中的redirect_url参数

image.png

转码后其实就是http://localhost:8080/auth/qq,也就是说如果用户同意授权那么浏览器将会重定向到联合登录的URL上。

而我在QQ互联中申请时填写的回调域是www.zhenganwen.top/socialLogin/qq(如下图),QQ联合登录要求用户同意授权之后重定向到的URL必须和申请appId时填写的回调域保持一致,也就是说页面上联合登录的URL必须和回调域保持一致。

image.png

首先域名和端口需要保持一致:

由于是本地服务器,因此我们需要修改本地hosts文件,让浏览器解析www.zhenganwen.top时解析到172.0.0.1

127.0.0.1 www.zhenganwen.top

并且将服务端口改为80

server.port=80

这样域名和端口能对应上了,能够通过www.zhenganwen.top/login.html访问登录页。

其次,还需要将联合登录URI和我们在设置的回调域对应上,/auth改为/socialLogin,需要自定义SocialAuthenticationFilterfilterProcessesUrl属性值:

新增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()是一个钩子函数,在SecurityConfigurerAdapterconfig方法中,在将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登录发现跳转如下

image.png

授权跳转逻辑走通!该阶段代码可参见:https://gitee.com/zhenganwen/code-demo/tree/21d708b6a45cbf2baab322470d96313f08b0c426/

阶段性小结

回调域解析

你是在本地80端口跑的服务,为什么认证服务器能够解析回调域www.zhenganwen.top/socialLogin/qq中的域名从而跳转到你的本地

注意上面授权登录页面的地址栏,URL附带了redirect_url这一参数,因此当你同意授权登陆后,跳转到redirect_url参数值这一操作是在你浏览器中进行的,而你在hosts中配置了127.0.0.1 www.zhenganwen.top,因此浏览器没有进行域名解析直接将请求/socialLogin/qq发送到了127.0.0.1:80上,也就是我们正在运行的security-demo服务

SpringSoicalConfigure的作用是什么?

直接上源码:

public class SpringSocialConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
	@Override
	public void configure(HttpSecurity http) throws Exception {		
		ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
		UsersConnectionRepository usersConnectionRepository = getDependency(applicationContext, UsersConnectionRepository.class);
		SocialAuthenticationServiceLocator authServiceLocator = getDependency(applicationContext, SocialAuthenticationServiceLocator.class);
		SocialUserDetailsService socialUsersDetailsService = getDependency(applicationContext, SocialUserDetailsService.class);
		
		SocialAuthenticationFilter filter = new SocialAuthenticationFilter(
				http.getSharedObject(AuthenticationManager.class), 
				userIdSource != null ? userIdSource : new AuthenticationNameUserIdSource(), 
				usersConnectionRepository, 
				authServiceLocator);
		
		...
                                          
		http.authenticationProvider(
				new SocialAuthenticationProvider(usersConnectionRepository, socialUsersDetailsService))
			.addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class);
	}
}                    

如果我们想将之前所写的SpringSoical组件都应用上,那就要遵循SpringSecurity的认证机制,即添加一个新的认证方式就需要添加一个XxxAuthenticationFilter,而SpringSoical已经帮我们实现了SocialAuthenticationFilter,因此我们只需要在过滤器中添加它就行。与我们之前将短信登录封装到SmsLoginConfig中一样,SpringSocial帮我们将社交登录封装到了SpringSocialConfigure中,这样只要业务系统(即依赖SpringSocial的应用)只需调用httpSecurity.apply(springSocialConfigure)即可启用社交登录功能。

并且除了将SoicalAuthenticationFilter添加到过滤器链中之外,SpringSocialConfigure还会将容器中的UsersConnectionRepositorySocialAuthenticationServiceLocator关联到SoicalAuthenticationFilter中,SoicalAuthenticationFilter通过前者能够根据OAuth流程获取的社交信息(providerIdproviderUserId)查询到userId,通过后者能够根据providerId获取对应的SocialAuthenticationService并从中获取到ConnectionFactory进行获取授权码、获取accessToken、获取用户社交信息等操作

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,然后将所有的SocialAuthenticationServiceproviderIdkey缓存在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路径中的providerIdSocialAuthenticationLocator中查找对应的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行,拿授权码获取accessTokenAccessGrant)、调用OpenAPI获取用户信息并适配成Connection

为什么同意授权后响应如下

image.png

我们扫描二维码同意授权,浏览器重定向到/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]

说明是在调用我们的OAuth2TemplateexchangeForAccess拿授权码获取accessToken时报错了,错误原因是在转换响应结果为AccessGrant时没有处理text/html的转换器。

首先我们看一下响应结果是什么:

image.png

发现响应结果是一个字符串,以&分割三个键值对,而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个转换器,FormHttpMessageConverterFormMapHttpMessageConverterMappingJackson2HttpMessageConverter分别对应解析Content-Typeapplication/x-www-form-urlencodedmultipart/form-dataapplication/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);
    }

}

现在我们能够拿到封装accessTokenAccessGrant了,再继续端点调试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;
		}
}

发现QQApiImplgetUserInfo存在同一的问题,调用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;
		}
}

这时会调用ProviderManagerauthenticateSocialAuthenticationToken进行校验,ProviderManager又会委托SocialAuthenticationProvider

SocialAuthenticationProvider会调用我们注入的JdbcUsersConnectionRepositoryUserConnection表中根据ConnectionproviderIdproviderUserId查找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;
		}
}

SocialAuthenticationFiltersignupUrl默认为/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("用户未登录,请引导用户至登录页");
    }
}

于是最终得到了如下响应:

image.png

@EnableSocial做了些什么

它会加载一个配置类SocialConfiguration,该类会读取容器中SocialConfigure实例,如我们所写的扩展SocialAutoConfigureAdapterQQLoginAutoConfig和扩展了SocialConfigureAdapterSocialConfig,将我们实现的ConnectionFactoryUsersConnectionRepositorySpringSecurity的认证流程串起来

/**
 * 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,并将userIdConnection.getProviderIdConnection.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";
  }
}                    

关联QQ账号.gif

绑定/解绑场景支持

有时我们的系统的账号管理模块需要允许用户关联或取消关联一些社交账号,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时调用),可通过回调参数获取requestresponse

通过.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中。

使用集群模式后,之前配置的timeouthttp.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(),指定注销操作请求的URL
  • logoutSuccessUrl(),注销完成后跳转到的URL
  • logoutSuccessHandler(),注销完成后调用的处理器,可根据用户请求类型动态响应页面或JSON
  • deleteCookies(),根据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

image.png

从本章开始,我们将采用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中授权模式中的授权码模式和密码模式

首先认证服务器端要有用户,为了方便这里就不再编写DAOUserDetailsService了,我们可以通过配置添加一个用户:

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-clientappSecret:123。现在我们的security-demo也成为了认证服务器,任何调用security-demoAPI获取token的其他应用可视为第三方应用或客户端了。

security.oauth2.client.client-id=test-client
security.oauth2.client.client-secret=123

接下来我们可以对照OAuth2的官网上的 参考文档来验证@EnableAuthorizationServer提供的4种授权模式并获取token

测试授权码模式

参见 请求标准

授权码模式有两步:

  1. 获取授权码

    观察boot启动日志,发现框架为我们添加若干接口,其中就包含了/oauth/authorize,这个就是授权码获取的接口。我们对照OAuth2中获取授权码的请求标准来尝试获取授权码

    image.png

    http://localhost/oauth/authorize?
    response_type=code
    &client_id=test-client
    &redirect_uri=http://example.com
    &scope=all
    

    其中response_type固定为code表示获取授权码,client_id为客户端的appIdredirect_uri为客户端接收授权码从而进一步获取token的回调URL(这里我们暂且随便写一个,到时候授权成功跳转到的URL上会附带授权码),scope表示此次授权需要获取的权限范围(键值和键值的意义应由认证服务器来定,这里我们暂且随便写一个)。访问该URL后,会弹出一个basic认证的登录框,我们输入用户名test密码test登录之后跳转到授权页,询问我们是否授予all权限(实际开发中我们可以将权限按操作类型分为createdeleteupdateread,也可按角色划分为useradminguest等):

    image.png

    我们点击同意Approve后点击授权Authorize,然后跳转到回调URL并附带了授权码

    image.png

    记下该授权码yO4Y6q用于后续的token获取

  2. 获取token

    image.png

    我们可以通过Chrome插件Restlet Client来完成此次请求

    1. 点击Add authorization输入client-idclient-secret,工具会帮我们自动加密并附在请求头Authorizatin
    2. 填写请求参数

    image.png

    如果使用PostmanAuthorization设置如下:

    image.png

    点击Send发送请求,响应如下:

    image.png

密码模式

密码模式只需一步,无需授权码,可以直接获取token

image.png

使用密码模式相当于用户告诉了客户端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拦截它会弹出登录框,而这里并没有

image.png

然后我们使用密码模式重新生成一次token:7f6c95fd-558f-4eae-93fe-1841bd06ea5c,并在访问接口时附带token(添加请求头Authorization值为token_type access_token

image.png

使用Postman更加方便:

image.png

Spring Security Oauth核心源码剖析

框架核心组件如下,方框为绿色表示是具体类,为蓝色则表示是接口/抽象,括号中的类为运行时实际调用的类。下面我们将以密码模式为例来对源码进行剖析,你也可以打断点逐步进行验证。

image.png

令牌颁发服务——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);

}

首先入参包含了两个部分:principalparameters,对应我们密码模式请求参数的两个部分:请求头Authorization和请求体(grant_typeusernamepasswordscope)。

String clientId = getClientId(principal);

principal传入的实际上是一个UsernamePasswordToken,对应逻辑在BasicAuthenticationFilterdoFilterInternal方法中:

@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,这实际上是我们传入的clientIdclientSecret使用冒号连接在一起之后再用base64加密算法得到的,因此在extractAndDecodeHeader方法中会对xxx进行base64解密得到由冒号分隔的clientIdclientSecret组成的密文(借用之前的clientId=test-clientclientSecret=123的例子,这里得到的密文就是test-client:123),最后将client-id作为usernameclientSecret作为password构建了一个UsernamePasswordToken并返回,因此在postAccessToken中的principal能够得到请求头中的clientId

ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);

接着调用ClientDetailsService根据clientId查询已注册的客户端详情,即ClientDetails,这是外部应用在注册security-demo这个开放平台时填写并经过审核的信息,包含若干项,我们这里只有clientIdclientSecret两项。(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(""))

接着对传入的clientIdauthenticatedClientclientId进行校验。也许你会问,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"],通过审核后该客户端请求获取tokentoken能够表明: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的请求。其实能够请求tokengrant_type除了oauth标准中的4中授权模式authorization_codeimplicitpasswordclient_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,这时有两种情况:

  1. 旧的token过期了,这时要将该token移除,如果该tokenrefresh_token还在则也要移除(请求刷新某token时需要其对应的refresh_token,如果token失效了则其伴随的refresh_token也应该不可用)
  2. 旧的token没有过期,重新保存一下该token(因为前后可能是通过不同授权模式生成token的,对应保存的逻辑也会有差别),并直接返回该token,方法结束。

如果没有从tokenStore中发现旧token,那么就新生成一个token,保存到tokenStore中并返回。

小结

image.png

集成用户名密码获取token

虽然框架已经帮我们封装好了认证服务器所需的4中授权模式,但是这这一般是对外的(外部应用无法读取我们系统的用户信息),用于构建开放平台。对于内部应用,我们还是需要提供用户名密码登录、手机号验证码登录等方式来获取token。首先,框架流程一直到TokenGranter组件这一部分我们是不能沿用了,因为已被OAuth流程固化了。我们所能用的就是令牌生成服务AuthorizationServerTokenServices,但它需要一个OAuth2Authentication,而我们构建OAuth2Authentication又需要tokenRequestauthentication

我们可以在原有登录逻辑的基础之上,修改登录成功处理器,在该处理器中我们能获取到认证成功的authentication,并且从请求头Authorization中获取到clientId调用注入的ClientDetailsService查出clientDetails并构建tokenRequest,这样就能调用令牌生成服务来生成令牌并响应了。

image.png

在登录成功处理器中调用令牌服务

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了:

  1. 请求头还是要附带客户端信息

    image.png
  2. 请求参数传用户名密码登录所需参数即可

    image.png
  3. 登录成功即获取token

    image.png
  4. 通过token访问服务

    由于Postman仍支持服务端写入和读取Cookie

    image.png

    为了避免Session/Cookie登录方式的影响,每次我们需要清除cookie再发送请求。

    image.png

    image.png

    首先是不附带token的请求,发现请求被拦截了:

    image.png

    然后附带token访问请求:

    image.png

至此,用户名密码登录获取token集成成功!

验证码和短信登录的集成流程类似,在此不再赘述。值得注意的是基于token的方式要摒弃对Session/Cookie的操作,可以将要保存在服务端的信息放入如Redis等持久层中。

集成社交登录获取token

在本节,我们将实现内部应用使用社交登录的方式向内部认证服务器获取token

简易模式

流程分析

如果内部应用采取的是简易模式,用户同意授权后直接获取到外部服务提供商发放的token,这时我们是没有办法拿这个token去访问内部资源服务器的,需要拿这个token去内部认证服务器换取我们系统内部通行的token

换取思路是,如果用户进行社交登录成功,那么内部应用就能够获取到用户的providerUserId(在外部服务提供商中称为openId),并且UserConnection表应该有一条记录(userId,providerId,providerUserId),内部应用只需将providerIdproviderUserId传给内部认证服务器,内部认证服务器查UserConnection表进行校验并根据userId构建Authentication即可生成accessToken

image.png

为此我们需要在内部认证服务器上写一套providerId+openId的认证流程:

image.png

其中UserConnectionRepositoryCustomUserDetailsServiceAppAuthenticationSuccessHandler都是现成的,可以直接拿来用。

SecurityProperties增加处理根据openIdtoken的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

image.png

并访问/user测试token有效性,访问成功!集成社交登录成功!

授权码模式

如果内部应用采用的是授权码模式,那么在外部服务提供商带着授权码回调时,内部应用直接将该回调请求转发到我们的认证服务器即可,因为我们此前已经写过社交登录模块,这样能够实现无缝衔接。

还是以我们之前实现的QQ登录为例:image.png

内部应只需在用户同意授权,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中,因为browserapp都有用到:

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):

image.png

访问www.zhenganwen.top/login.html进行QQ授权登录(同时打开浏览器控制台),同意授权进行跳转,停在断点后停掉服务,在浏览器控制台中找到回调URL并复制它:

image.png

再将security-demopom切换为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了:

image.png

这里认证服务器在拿授权码获取token时返回异常信息code is reused error(授权码被重复使用),按理来说前一次我们打了断点并及时停掉了服务,该授权码没拿去请求token过才对,这里的错误还有待排查。

处理器模式

其实就算token获取成功,也不会响应我们想要的accessToken,因为此前在配置SocialAuthenticationFilter时并没有为其制定认证成功处理器,因此我们要将AppAuthenticationSuccessHandler设置到其中,这样社交登录成功后才会生成并返回我们要向的token

下面我们就用简单但实用的处理器重构手法来再security-app中为security-coreSocialAuthenticationFilter做一个增强:

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初始化完成之前都调用postProcessBeforeInitializationbean初始化完毕后调用postProcessAfterInitialization,若不想进行增强则可以返回传入的bean,若想有针对性的增强则可根据传入的beanName进行筛选。

public interface BeanPostProcessor {
	Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException;
	Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException;
}

我们可以该接口的一个实现类SpringSocialConfigurerPostProcessorQQSpringSocialConfigurer bean初始化完成后重设configure.signupUrl,当UserConnection没有对应Connection关联记录时跳转到signupUrl对应的服务。

在这个服务中应该返回一个JSON提示前端需要关联社交账号(并将之前走OAuth获取到的第三方用户信息由ProviderSignInUtilsSession中取出并使用RedisProviderSignInUtils暂存到Redis中),而不应该向之前设置的那样跳转到社交账号关联页面。返回信息格式参考如下:image.png

令牌配置

存储方式

token默认是存储在内存中的,所以一旦重启服务,那么此前颁发的token都会失效。security支持我们将token存储在redis中:

@Configuration
public class TokenStoreConfig {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore tokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }
}
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService customUserDetailsService;

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager).userDetailsService(customUserDetailsService);
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
            	// 配置一个客户端,使用and()可接着配置另一个
                .withClient("test-clientId")
            		// 该客户端的 clientSecret
                    .secret("test-clientSecret")
            		// token过期时间,单位秒
                    .accessTokenValiditySeconds(7200)
            		// 该客户端只能通过一下授权模式获取token
                    .authorizedGrantTypes("refresh_token", "password")
            		// 该客户端请求授权时传入的scopes必须包含在下列scopes中
                    .scopes("write", "read");
    }
}

该配置会覆盖application.properties中的oauth client配置:

#security.oauth2.client.client-id=test-clientId
#security.oauth2.client.client-secret=test-clientSecret

重启服务获取token,打开redis-cli查询keys *发现token及其对应的信息存储到了redis

使用JWT替换默认令牌

security默认使用UUID生产的随机串作为token的内容,该内容没有任何意义,token代表的访问权限等信息需要额外的辅助字段来存储,也就是说token内容和token含义是分离的,如果丢失了辅助字段,那么该token就是去了价值。

Json Web Token(JWT)是一种token定义标准,它具有以下特点:

  • 自包含。在保证token内容的唯一性和无规则性的同时,将该内容通过解密能够获取到该token的附加信息,token附加信息包含于token自身
  • 防伪造/防篡改。认证服务器颁发jwt token时可使用一个任意字符串对该token进行签名,在校验token时可使用相同的字符串对该token进行验签,确保他人伪造/篡改token
  • 可扩展。除标准token必须附带的信息之外,可向其中添加一些扩展信息以个性化token
@Configuration
public class TokenStoreConfig {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    // 当配置项 demo.security.oauth2.tokenType包含redis时生效
    @Bean
    @ConditionalOnProperty(prefix = "demo.security.oauth2", name = "tokenType", havingValue = "redis")
    public TokenStore tokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }

    // 当配置项 demo.security.oauth2.tokenType包含jwt或没有该配置项时生效
    @Configuration
    @ConditionalOnProperty(prefix = "demo.security.oauth2", name = "tokenType", havingValue = "jwt", matchIfMissing = true)
    public static class JwtConfig {

        @Autowired
        private SecurityProperties securityProperties;

        @Bean
        public TokenStore tokenStore() {
            // 以jwt的方式存储token
            return new JwtTokenStore(jwtAccessTokenConverter());
        }

        @Bean
        public JwtAccessTokenConverter jwtAccessTokenConverter() {
            JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
            // 设置签名字符串
            converter.setSigningKey(securityProperties.getOauth2().getJwtSingingKey());
            return converter;
        }

        @Bean
        public TokenEnhancer tokenEnhancer() {
            return new MyTokenEnhancer();
        }
    }
}
public class MyTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Map<String, Object> info = new HashMap<>();
        info.put("company", "tuhu");

        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info);
        return accessToken;
    }
}
@Configuration
@EnableAuthorizationServer
public class MyAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService customUserDetailsService;

    @Autowired
    private TokenStore tokenStore;

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired(required = false)
    private JwtAccessTokenConverter jwtAccessTokenConverter;

    @Autowired(required = false)
    private TokenEnhancer tokenEnhancer;

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .tokenStore(tokenStore)
                .authenticationManager(authenticationManager)
                .userDetailsService(customUserDetailsService);

        if (jwtAccessTokenConverter != null && tokenEnhancer != null) {
			// token增强链
            TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
            // token增强器集合
            List<TokenEnhancer> tokenEnhancers = new ArrayList<>();
            // 使用MyTokenEnhancer向token中添加额外附加信息(company字段)
            tokenEnhancers.add(tokenEnhancer);
            // 将默认按UUID生成的token转换为jwt token
            tokenEnhancers.add(jwtAccessTokenConverter);
            tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);
			// 应用token增强链
            endpoints.tokenEnhancer(tokenEnhancerChain);

            // 以jwt的标准校验token
            endpoints.accessTokenConverter(jwtAccessTokenConverter);
        }
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        InMemoryClientDetailsServiceBuilder builder = clients.inMemory();
        if (ArrayUtils.isNotEmpty(securityProperties.getOauth2().getClients())) {
            for (ClientProperties client : securityProperties.getOauth2().getClients()) {
                builder
                        .withClient(client.getClientId())
                        .secret(client.getClientSecret())
                        .accessTokenValiditySeconds(client.getTokenValidSeconds())
                        .authorizedGrantTypes("refresh_token", "password")
                        .refreshTokenValiditySeconds(604800)
                        .scopes("write", "read");
            }
        }
    }
}
demo.security.oauth2.clients[0].clientId=anwen
demo.security.oauth2.clients[0].clientSecret=anwenSecret
demo.security.oauth2.clients[0].tokenValidSeconds=3600

demo.security.oauth2.clients[1].clientId=zanwen
demo.security.oauth2.clients[1].clientSecret=zanwenSecret

基于JWT实现SSO单点登录

架构

  • sso-server
  • sso-client1
  • sso-client2

效果:访问127.0.0.1:8080/client1,跳转到sso-server进行basic登录,输入user 123登录成功回调sso-client1,点击超链接免登录跳转到sso-client2

实现

sso-server

@SpringBootApplication
@RestController
public class SsoServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(SsoServerApplication.class, args);
    }
}
@Configuration
@EnableAuthorizationServer
public class SsoAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("anwen");
        return converter;
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.tokenKeyAccess("isAuthenticated()");
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(tokenStore()).accessTokenConverter(jwtAccessTokenConverter());
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("app1")
                .secret("app1Secret")
                .authorizedGrantTypes("authorization_code", "refresh_token")
                .scopes("all")
                .and()
                .withClient("app2")
                .secret("app2Secret")
                .authorizedGrantTypes("authorization_code", "refresh_token")
                .scopes("all");

    }
}
server.port=9999
server.context-path=/server
# 设置user用户的密码为123,用于basic登录
security.user.password=123

sso-client1

@SpringBootApplication
@RestController
@EnableOAuth2Sso
public class SsoClient1Application {

    public static void main(String[] args) {
        SpringApplication.run(SsoClient1Application.class, args);
    }
}
security.oauth2.client.clientId=app1
security.oauth2.client.clientSecret=app1Secret
security.oauth2.client.user-authorization-uri=http://127.0.0.1:9999/server/oauth/authorize
security.oauth2.client.access-token-uri=http://127.0.0.1:9999/server/oauth/token
# 从认证服务器获取jwt签名的接口,只有受信任的clientId才能获取到
security.oauth2.resource.jwt.key-uri=http://127.0.0.1:9999/server/oauth/token_key

server.port=8080
server.context-path=/client1
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Sso Client1</title>
</head>
<body>
Welcome to Sso Client1!
<a href="http://localhost:8060/client2/index.html">点击访问Sso Client2</a>
</body>
</html>

sso-client2

security.oauth2.client.clientId=app2
security.oauth2.client.clientSecret=app2Secret

security.oauth2.client.user-authorization-uri=http://127.0.0.1:9999/server/oauth/authorize
security.oauth2.client.access-token-uri=http://127.0.0.1:9999/server/oauth/token
security.oauth2.resource.jwt.key-uri=http://127.0.0.1:9999/server/oauth/token_key

server.port=8060
server.context-path=/client2

刷新token

指定grant_typerefresh_token,参数refresh_token指定为要刷新tokenrefresh_token

授权

AnonymousAuthenticationFilter

image.png

该过滤器位于表单登录UsernamePasswordAuthenticationFilter,弹框登录HttpBasicAuthenticationFilter之后,是认证过滤器链的最后一个,当请求走过前面存在的一系列认证过滤器后,AnonymousAuthenticationFilter会检查当前线程中是否包含认证成功的Authentication,如果没有,则会塞一个AnonymousAuthenticationSecurityContext

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {

    if (SecurityContextHolder.getContext().getAuthentication() == null) {
        SecurityContextHolder.getContext().setAuthentication(
            createAuthentication((HttpServletRequest) req));
    }
    chain.doFilter(req, res);
}

protected Authentication createAuthentication(HttpServletRequest request) {
    AnonymousAuthenticationToken auth = new AnonymousAuthenticationToken(key,
                                                                         principal, authorities);
    auth.setDetails(authenticationDetailsSource.buildDetails(request));

    return auth;
}

FilterSecurityInterceptor

该过滤器会根据其前面认证过滤器放入SecurityContext中的Authentication(即使未登录过,也有一个AnonymousAutentication)的getAuthorities方法获取到用户的授权信息(Collection<? extends GrantedAuthority>),与HttpSecurity配置的authorizeRequests中设置的URL和权限绑定信息进行决策,看该用户是否有访问该URL的权限。

image.png

权限表达式

权限表达式.png

image.png

通过authorizeRequests(),我们可以配置若干个antMatchers来指定一系列的URL需要配置什么样的权限。之前我们调用hasRole("ADMIN"),却要在构建UserDetails时返回ROLE_ADMIN,这是因为前者会自动加一个前缀ROLE_,这是为了和不会加前缀的hasAuthority("ADMIN")分隔开。

但是antMatchers()之后不支持链式调用,也就是说如果我们希望访问/xxx需要ADMIN角色和请求IPx.x.x.x是无法通过配置antMatchers("/xxx").hasRole("ADMIN").hasIpAddress("x.x.x.x")来实现,像这种多权限并存的情况,我们可以使用access()方法:antMatchers("/xxx").access("hasRole(ADMIN) and hasIpAddress(x.x.x.x)")

.antMatchers("/user").access("hasRole('ADMIN') and hasIpAddress('127.0.0.1')")

值得注意的是,在本地测试时应该使用127.0.0.1而不是localhost,否则测试hasIpAddress('127.0.0.1')会有问题

能否自定义权限表达式判断request是否该被拒绝?我们将在下节揭幕

基于数据库Rbac数据模型控制权限

上节中关于security内建的权限表达式以及access()可结合and/or联合多个内建表达式,这对于角色较少且很少变动的系统(如,对用户的电商面门系统,一般只有游客/普通用户/普通会员/超级会员这几个角色)是能够满足需求的。但对于角色多变/角色受众多变的系统(如,电商运营系统,可能会有采购专员/售后专员/..专员/管理员多种角色,且受人事变动的影响,用户-角色的映射关系会频繁修改/新增/删除,这种情况下,通过权限表达式写死在代码里是不科学的,我们需要将其维护到数据库表中

一个典型的Rabc需要五张表,如下所示

image.png

public interface AccessControlService {

    /**
     * 从request中获取URI,读取用户的所有访问权限,判断是否包含此URI
     * @param request
     * @param authentication
     * @return
     */
    boolean hasPermission(HttpServletRequest request, Authentication authentication);
}
@Component("rbacService")
public class RoleBasedAccessControlService implements AccessControlService {

    private AntPathMatcher antPathMatcher = new AntPathMatcher();

    private static List<String> uris = new ArrayList<>();

    static {
        uris.add("/user");
    }

    @Override
    public boolean hasPermission(HttpServletRequest request, Authentication authentication) {

        boolean hasPermission = false;
        String requestURI = request.getRequestURI();

        String username;
        if (authentication.getPrincipal() instanceof UserDetails) {
            // 是基于session方式登录
            username = ((UserDetails) authentication.getPrincipal()).getUsername();
        } else {
            // 基于jwt
            username = authentication.getPrincipal().toString();
        }
        // 从数据库中根据 username 查询用户可访问的所有uri放入uriSet
        Set<String> uriSet = new HashSet<>();
        // 假设查出来的是 uris
        uriSet.addAll(uris);
        // 遍历 uriSet,判断请求的uri是否包含在其中
        for (String uri : uriSet) {
            // 数据库中存的可能是 /user/** 这种带有通配符的uri,需使用 AntPathMatcher#match
            if (antPathMatcher.match(uri, requestURI)) {
                hasPermission = true;
                break;
            }
        }

        return hasPermission;
    }
}
@Configuration
public class RabcExpressionConfig {
    // 如果是通过oauth2登录而不是基于session的方式登录,那么解析自定义权限表达式需要加入该bean
    @Bean
    public OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler(ApplicationContext applicationContext) {
        OAuth2WebSecurityExpressionHandler expressionHandler = new OAuth2WebSecurityExpressionHandler();
        expressionHandler.setApplicationContext(applicationContext);
        return expressionHandler;
    }
}
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private OpenIdAuthenticationConfig openIdAuthenticationConfig;

    @Autowired
    private QQSpringSocialConfigurer qqSpringSocialConfigurer;

    @Autowired
    private OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.expressionHandler(oAuth2WebSecurityExpressionHandler);
    }

    @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()
                .anyRequest().access("@rbacService.hasPermission(request,authentication)")
                .and()
                .csrf().disable();
    }
}

使用依赖查找剥离authorizeRequests配置

此前对于URL访问控制的配置,我们都集中放在了ResourceServerConfigurerAdapter.configure中的authorizeRequests后面,但是在开发我们的安全模块的时候,我们并不知道以后依赖本模块的其他模块需要放开哪些URL又或是要对那些URL进一步做权限控制。介于此,我们应该提供一个接口给将来依赖本模块的其他模块去注入他们个性化配置。

image.png

public interface AuthorizeRequestConfigProvider {
    /**
     * 通过此方法的入参{@code config},你可以拿到{@code authorizeRequests()}的返回值,进行uri权限配置
     * @param config
     */
    void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config);
}
/**
 * @author zhenganwen
 * @date 2019/10/15
 * {@code @Order(Integer.MIN_VALUE)} 保证该bean排在其他 AuthorizeRequestConfigProvider 后面加载,
 *  避免{@code .anyRequest().authenticated()}覆盖其他模块的uri权限配置
 */
@Component
@Order(Integer.MIN_VALUE)
public class AppAuthorizeRequestConfig implements AuthorizeRequestConfigProvider {

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    public void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config) {
        config
                .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();
    }
}
@Component
@Order(Integer.MAX_VALUE)
public class DemoAuthorizeRequestConfig implements AuthorizeRequestConfigProvider {
    @Override
    public void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config) {
        config
                .anyRequest().access("@rbacService.hasPermission(request,authentication)");
    }
}
@Component
public class AuthorizeRequestConfigManager {

    @Autowired
    private List<AuthorizeRequestConfigProvider> authorizeRequestConfigProviders = new ArrayList<>();

    public void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config) {
        if (CollectionUtils.isNotEmpty(authorizeRequestConfigProviders)) {
            for (AuthorizeRequestConfigProvider authorizeRequestConfigProvider : authorizeRequestConfigProviders) {
                authorizeRequestConfigProvider.config(config);
            }
        }
    }
}
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private OpenIdAuthenticationConfig openIdAuthenticationConfig;

    @Autowired
    private QQSpringSocialConfigurer qqSpringSocialConfigurer;

    @Autowired
    private OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler;

    @Autowired
    private AuthorizeRequestConfigManager authorizeRequestConfigManager;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.expressionHandler(oAuth2WebSecurityExpressionHandler);
    }

    @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.csrf().disable();

        authorizeRequestConfigManager.config(http.authorizeRequests());
    }
}

参考资料


标题:Spring Security 技术栈开发企业级认证授权
作者:zanwen
地址:http://www.zhenganwen.top/articles/2019/08/29/1567053196289.html

评论