Official documentation reference, 5.1.2 Chinese Reference documentation, 4.1 Chinese Reference Documentation, 4.1 Chinese translation and source interpretation of official documentation

SpringSecurity core features:

  • Certification (Who are you)
  • Empower (What can you do)
  • Attack Protection (preventing identity forgery)

Simple start

Pom depends on

<?xml version="1.0" encoding="UTF-8"? >
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.1.4. The RELEASE</version>
		<relativePath/> <! -- lookup parent from repository -->
	</parent>
	<groupId>org.woodwhale.king</groupId>
	<artifactId>security-demo</artifactId>
	<version>1.0.0</version>
	<name>security-demo</name>
	<description>spring-security-demo project for Spring Boot</description>

	<properties>
		<java.version>1.8</java.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
        
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>
Copy the code

Write the simplest user, controller

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/user")
public class UserController {
    @GetMapping
    public String getUsers(a) {       
        return "Hello Spring Security"; }}Copy the code

Application. yml Configures the IP address and port

server:
  address: 127.0. 01.
  port: 8081
  
logging:
  level:
    org.woodwhale.king: DEBUG
Copy the code

The browser to http://127.0.0.1:8081/user, the browser automatically be redirected to the login interface:

This /login access path in the program does not have any display code, why there is such an interface, the current interface of the UI is from where?

Spring-security does the default control, of course. From the startup log, you can see that a string of user names default to user password:

After a successful login, the service resources can be accessed normally.

You can customize the default user name and password

Configure the user name and password in the configuration file:

spring:
  security:
    user:
      name: "admin"
      password: "admin"
Copy the code

Turn off the default secure access control

Older versions of Spring Security turned off the default secure access control by simply turning it off in the configuration file:

security.basic.enabled = false
Copy the code

The new version of spring-boot2.xx (spring-security5.x) no longer provides the above configuration:

Approach 1: Remove the Security package from the project dependencies.

Method 2: will org. Springframework. Boot. Autoconfigure. Security. Servlet. SecurityAutoConfiguration not into the spring:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;

@SpringBootApplication
@EnableAutoConfiguration(exclude = {SecurityAutoConfiguration.class})
public class SecurityDemoApplication {

	public static void main(String[] args) { SpringApplication.run(SecurityDemoApplication.class, args); }}Copy the code

Method 3: has implemented a configuration class inherits from WebSecurityConfigurerAdapter, and rewrite the configure (HttpSecurity HTTP) methods:

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.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/ * *").permitAll();
    }
 
    /** * Configure a userDetailsService Bean * no longer generates the default security.user user */
    @Bean
    @Override
    protected UserDetailsService userDetailsService(a) {
        return super.userDetailsService(); }}Copy the code

Note: WebSecurityConfigurerAdapter is an adapter class, so in order to make the custom configuration class see know righteousness, so wrote WebSecurityConfig. The @enableWebSecurity annotation was added to Spring Security.

Custom user authentication

Precautions for configuring security authentication

Springsucrity custom user authentication configuration of core in the WebSecurityConfigurerAdapter type, the user wants to personalized user authentication logic, you need to write a custom configuration classes, adapted to the spring security:

Note: if the configuration of two or more custom implementation class, then will quote WebSecurityConfigurers not only error: Java. Lang. An IllegalStateException: @Order on WebSecurityConfigurers must be unique.

@Configuration
@EnableWebSecurity
public class BrowerSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()               // Define the login page to go to when a form needs to be submitted for user login.
                .and()
                .authorizeRequests()   // Define which urls need to be protected and which do not
                .anyRequest()          // Any request can be accessed after login.authenticated(); }}Copy the code

Customize the user name and password

Password encryption precautions

After the user name and password are set to the memory, the user will verify the user name and password configured in the memory when logging in.

In older versions of Spring Security, configure the following code in the above custom BrowerSecurityConfig:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication().withUser("admin").password("admin").roles("ADMIN");
}
Copy the code

However, in the new version, there is no problem with startup and operation. Once the user logs in correctly, an exception will be reported:

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
Copy the code

Because of the new encryption methods in Spring Security 5.0, the password format has also changed. Official document: Password Storage Format

Spring Security now stores passwords in “{id}…” . The id in front is the encryption mode. The ID can be bcrypt, SHA256, etc., followed by the password that is encrypted using this encryption type.

Therefore, when the program receives the password queried in memory or database, it first looks for the ID included in {} to determine the encryption type of the following password. If it cannot find the password, it considers the ID as null. That’s why it gets an error: There is no PasswordEncoder mapped for the ID “null”. The official document provides an example of how different encryption methods can be used to encrypt the same password. The original password is “password”.

Password encryption

In order for our project to be able to log in normally, we need to encrypt the password sent from the front end in some way. The official recommendation is to use bcrypt encryption (the ciphertext generated without the user using the same original password is different), so we need to specify the following in configure method:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    // auth.inMemoryAuthentication().withUser("admin").password("admin").roles("ADMIN");
    auth.inMemoryAuthentication()
        .passwordEncoder(new BCryptPasswordEncoder())
        .withUser("admin")
        .password(new BCryptPasswordEncoder().encode("admin"))
        .roles("ADMIN");
}
Copy the code

There is, of course, another way to isolate the passwordEncoder configuration:

@Bean
public BCryptPasswordEncoder passwordEncoder(a) {
    return new BCryptPasswordEncoder();
}
Copy the code

Custom to memory

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
        .withUser("admin")
        .password(new BCryptPasswordEncoder().encode("admin"))
        .roles("ADMIN");
}
Copy the code

Customize to code

Here there is a more elegant way to implement org. Springframework. Security. Core. The populated userdetails. UserDetailsService interface, When the user logs in, loadUserByUsername(String username) of the UserDetailsService interface is called to verify the validity of the user (password and permission).

This method lays a technical feasibility foundation for combining database or JWT dynamic verification.

@Service
public class MyUserDetailsService implements UserDetailsService {

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		Collection<GrantedAuthority> authorities = new ArrayList<>();
		authorities.add(new SimpleGrantedAuthority("ADMIN"));
		return new User("root".new BCryptPasswordEncoder().encode("root"), authorities); }}Copy the code

“Custom to memory”, of course, the configure of the configuration file (AuthenticationManagerBuilder auth) configuration will not need to configure it again.

Note: For the returned UserDetails implementation class, you can use the framework’s own User, or you can implement a UserDetails implementation class in which the password and permissions should be read from the database rather than written to death in code.

Best practices

Abstract the encryption type, realize UserDetailsService interface, the two injection into AuthenticationManagerBuilder:

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	@Autowired
	private UserDetailsService userDetailsService;
    
    @Bean
	public BCryptPasswordEncoder passwordEncoder(a) {
		return new BCryptPasswordEncoder();
	}
	
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService) .passwordEncoder(passwordEncoder()); }}Copy the code

UserDetailsService interface implementation class:

import java.util.ArrayList;
import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
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.stereotype.Service;

@Service
public class MyUserDetailsService implements UserDetailsService {

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		Collection<GrantedAuthority> authorities = new ArrayList<>();
		authorities.add(new SimpleGrantedAuthority("ADMIN"));
		return new User("root".new BCryptPasswordEncoder().encode("root"), authorities); }}Copy the code

The User object is a User object provided by the framework. Note that the package name is: Org. Springframework. Security. Core. Populated userdetails. The User, the inside of the attributes of the most core is the password, username, and authorities.

User-defined security authentication configuration

To configure a customized login page:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.formLogin() 								// Define the login page to go to when the user needs to log in.
        .loginPage("/login")	 					// Set the login page
        .loginProcessingUrl("/user/login") 			// Custom login interface
        .defaultSuccessUrl("/home").permitAll()		// After a successful login, the default page is redirected
        .and().authorizeRequests()					// Define which urls need to be protected and which do not
        .antMatchers("/"."/index"."/user/login").permitAll()		// Make the login page accessible to all
        .anyRequest().authenticated() 				// Any request can be accessed after login
        .and().csrf().disable(); 					// Disable CSRF protection
}
Copy the code

From the above configuration, it can be seen that all visitors can freely login/and /index for resource access, at the same time, a login interface /lgoin is configured, and MVC is used to do view mapping (mapping to login.html in the template file directory). The controller mapping code is too simple to be described. After a successful login, the /home page is automatically displayed.

The configuration shown in the above image is slightly flawed. When the.loginProcessURL () configuration is removed, the browser will continue redirecting until the redirection fails. Because the url for the successful login is not configured to be accessible to all, the result is an endless loop.

Therefore, to configure the login interface, you need to configure any accessible:.antmatchers (“/user/login”).permitall ()

The login. HTML code:


      
<html>
<head>
<meta charset="UTF-8">
<title>The login page</title>
</head>
<body>
	<h2>Customize the login page</h2>
	<form action="/user/login" method="post">
		<table>
			<tr>
				<td>User name:</td>
				<td><input type="text" name="username"></td>
			</tr>
			<tr>
				<td>Password:</td>
				<td><input type="password" name="password"></td>
			</tr>
			<tr>
				<td colspan="2"><button type="submit">The login</button></td>
			</tr>
		</table>
	</form>
</body>
</html>
Copy the code

Static resources ignore configuration

During user authentication, resource files are blocked by the security framework. Therefore, security configuration is required:

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers("/webjars/**/*"."/**/*.css"."/**/*.js");
}
Copy the code

Static resources for front-end frameworks can now be managed using Webjars, so configure /webjars/**/*.

Handle different types of requests

In a system where the front and back ends are separated, the back end only provides data in JSON format for the front-end to call by itself. Just like that, the protected interface was called and the page was directly jumped, which was acceptable on the Web side, but not on the App side, so we need to do further processing. So let’s do a little bit of thinking here

Here is an idea that is at the heart of using the security framework: RequestCache and RedirectStrategy

import java.io.IOException;

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

import org.springframework.http.HttpStatus;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
public class BrowserSecurityController {

    // Cache and restore the original request information
    private RequestCache requestCache = new HttpSessionRequestCache();

    // For redirection
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    /** * Jump to * when authentication is required@param request
     * @param response
     * @return* /
    @RequestMapping("/authentication/require")
    @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
    public String requireAuthenication(HttpServletRequest request, HttpServletResponse response) throws IOException {
        SavedRequest savedRequest = requestCache.getRequest(request, response);

        if(savedRequest ! =null) {
            String targetUrl = savedRequest.getRedirectUrl();
            log.info("The request to trigger the jump is :" + targetUrl);
            if (StringUtils.endsWithIgnoreCase(targetUrl, ".html")) {
                redirectStrategy.sendRedirect(request, response, "/login.html"); }}return "Services accessed require authentication. Please direct user to login page."; }}Copy the code

Note: this /authentication/require needs to be configured to the security authentication configuration: configured as the default login interface and set to be accessible by anyone, and the redirected page can be configured to read from the configuration file.

Custom processing login success/failure

In the case of separation of the front and back ends, we may need to return the user’s personal information to the front end after a successful login, instead of directly jumping to the front end. The same goes for login failures. Here involves the two interface AuthenticationSuccessHandler and AuthenticationFailureHandler Spring Security. Customize the implementation of these two interfaces and configure them accordingly. Of course the framework has a default implementation class. We can inherit this implementation class and customize our own business:

Successful login processing class

import java.io.IOException;

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component("myAuthenctiationSuccessHandler")
public class MyAuthenctiationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        log.info("Login successful");
        response.setContentType("application/json; charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(authentication)); }}Copy the code

After a successful login, a JSON string is returned via Response. The third parameter in this method is Authentication, which contains the UserDetails, Session information, login information, etc.

JSON response after successful login:

{
    "authorities": [{"authority": "ROLE_admin"}]."details": {
        "remoteAddress": "127.0.0.1"."sessionId": "8BFA4F61A7CEA774C00F616AAE8C307C"
    },
    "authenticated": true."principal": {
        "password": null."username": "admin"."authorities": [{"authority": "ROLE_admin"}]."accountNonExpired": true."accountNonLocked": true."credentialsNonExpired": true."enabled": true
    },
    "credentials": null."name": "admin"
}
Copy the code

Here’s one detail to note:

ROLE_admin = ROLE_admin; ROLE_admin = ROLE_admin; Admin, so the ROLE_ prefix is added by the framework itself, and attention should be paid to this detail when fetching the permission set in the later stage, depending on whether the permission is determined by the inclusion relation of string or the equivalence relation.

Login failure handling class

import java.io.IOException;

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component("myAuthenctiationFailureHandler")
public class MyAuthenctiationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {

        log.info("Login failed");
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        response.setContentType("application/json; charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(exception.getMessage())); }}Copy the code

Configure two custom processing classes into a custom configuration file:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.woodwhale.king.handler.MyAuthenctiationFailureHandler;
import org.woodwhale.king.handler.MyAuthenctiationSuccessHandler;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	
	@Autowired
	private MyAuthenctiationFailureHandler myAuthenctiationFailureHandler;
	
	@Autowired
	private MyAuthenctiationSuccessHandler myAuthenctiationSuccessHandler;
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.formLogin() 								// Define the login page to go to when the user needs to log in.
			.loginPage("/login")	 					// Set the login page
			.loginProcessingUrl("/user/login") 			// Custom login interface
			.successHandler(myAuthenctiationSuccessHandler)
			.failureHandler(myAuthenctiationFailureHandler)
			//.defaultSuccessURL ("/home").permitall () // After a successful login, the page is redirected by default
			.and().authorizeRequests()					// Define which urls need to be protected and which do not
			.antMatchers("/"."/index").permitAll()		// Make the login page accessible to all
			.anyRequest().authenticated() 				// Any request can be accessed after login
			.and().csrf().disable(); 					// Disable CSRF protection
	}

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.inMemoryAuthentication()
			.passwordEncoder(new BCryptPasswordEncoder()).withUser("admin")
			.password(new BCryptPasswordEncoder().encode("admin"))
			.roles("admin"); }}Copy the code

Note: defaultSuccessUrl no longer needs to be configured, and if configured, the handler that successfully logged in will not work.

summary

As you can see, with a custom login success or failure class for login response control, you can design a configuration that flexibly ADAPTS whether the response returns a page or JSON data.

Combining with the thymeleaf

Thymeleaf is used for rendering in the front end, and the envoy is combined with Spring Security to get user information in the front end

Dependency add:

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
Copy the code

Note:

Since this project uses Spring Boot to automatically manage the version number, the imported version must match exactly. If it is an old Spring Security version, you need to manually import the corresponding version.

Citation Official edition Citation notes:

thymeleaf-extras-springsecurity3 for integration with Spring Security 3.x
thymeleaf-extras-springsecurity4 for integration with Spring Security 4.x
thymeleaf-extras-springsecurity5 for integration with Spring Security 5.x
Copy the code

You can view the syntax: github.com/thymeleaf/t…

Common syntax labels

JSON data for the successful response of “custom processing login success/failure” from the previous section is referenced for convenience:

{
    "authorities": [{"authority": "ROLE_admin"}]."details": {
        "remoteAddress": "127.0.0.1"."sessionId": "8BFA4F61A7CEA774C00F616AAE8C307C"
    },
    "authenticated": true."principal": {
        "password": null."username": "admin"."authorities": [{"authority": "ROLE_admin"}]."accountNonExpired": true."accountNonLocked": true."credentialsNonExpired": true."enabled": true
    },
    "credentials": null."name": "admin"
}
Copy the code

SEC :authorize=”isAuthenticated() : Determines whether any authentication passes

SEC :authorize=”hasRole(‘ROLE_ADMIN’)” Determines whether there are ROLE_ADMIN permissions

Note: The hasRole() tag above can only be used successfully if: The user’s permission character set must be prefixed with ROLE_; otherwise, it cannot be parsed. That is, the permission field of the user’s permission arraylist returned by the user-defined UserDetailsService implementation class must be ROLE_***, and the corresponding XMLNS must be introduced in the HTML page. This example quotes:

<html xmlns:th="http://www.thymeleaf.org"
	xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
Copy the code

SEC :authentication=”principal.authorities” : Obtain a list of all the authorities of the user

SEC :authentication=”principal.username” : indicates the username of the user

Of course, you can get more information, as long as the user returned in the UserDetailsService implementation class carries any information.

Common exception class

AuthenticationException Common subclasses: (which can be the underlying change, do not recommend using) UsernameNotFoundException users find BadCredentialsException bad credentials AccountStatusException abnormal state it contains the following subclasses: (recommended) AccountExpiredException account overdue LockedException account lock DisabledException account unavailable CredentialsExpiredException certificate has expiredCopy the code

References:

Blog.csdn.net/u013435893/…

Blog.csdn.net/canon_in_d_…

Juejin. Cn/post / 684490…

www.jianshu.com/p/6307c89fe…

Mp.weixin.qq.com/s/NKhwU6qKK…

Mp.weixin.qq.com/s/sMi1__Rw_…

Blog.csdn.net/smd25756245…

www.cnblogs.com/yyxxn/p/880…

Blog.csdn.net/coder_py/ar…

Reference project source code:

Github.com/whyalwaysme…

Github.com/oycyqr/Spri…

Github.com/chengjiansh…

Personal blog: Woodwhale’s Blog

Blogland: The blog of the wooden whale