1. Complete authentication and authorization based on OAuth2.0 + JWT + Spring Security
- Single sign-on (SSO) solution Single sign-on solution is the most common solution, but single sign-on requires that each service interacting with the user must communicate with the authentication service, which not only causes duplication, but also generates a lot of trivial network traffic;
- Distributed Session scheme stores user Session information in shared storage, such as Redis, and uses user Session ID as key to realize distributed hash mapping. When a user accesses a microservice, session data can be retrieved from the shared storage. This solution is good in high availability and scalability, but because session information is stored in shared storage, it needs some protection mechanism to protect data security, so it will have high complexity in the concrete implementation.
- ** Client Token ** Scheme Token is generated by the client and signed by the authentication server. There is enough information in the token that the client will append to the request to provide user identity data for each microservice. This scheme solves the security problem of distributed session scheme, but how to log out user authentication information in time is a big problem. Although short-term token can be used and authentication server can be checked frequently, it cannot be solved completely. JWT (JSON Web Tokens) is a well-known client-side token solution that is simple enough and supports a wide range of environments
- Client token combined with API Gateway By implementing API gateway in the microservices architecture, the original client token can be transformed into an internal session token. On the one hand, microservices can be effectively hidden; on the other hand, token logout processing can be realized through the unified entry of API gateway. David Borsos’ second solution, the Distributed Session solution, requires developers to be able to separate user Session information for centralized management. Spring Session, a mature open source project in the industry, uses Redis database or cache mechanism to realize Session storage and realizes automatic loading of Session data through filters. With the development of cloud service applications in recent years, token-based authentication is widely used. Token-based authentication usually has the following implications:
- A token is a collection of information about an authenticated user, not just a meaningless ID.
- With enough information already contained in the token, the authentication token can complete the verification of the user’s identity, thus relieving the pressure of retrieving the database for user authentication and improving system performance.
- Since tokens need to be signed and issued by the server, if the token is authenticated by decoding, we can assume that the information contained in the token is valid.
- The server takes the token information and checks it through Authorization in the HTTP header, without storing any information on the server side.
- Token-based authentication can be used on browser-based clients, mobile device apps or third-party applications through the token checking mechanism of the server. · Cross-program calls can be supported. Cookie-based access is not allowed to crash, whereas tokens are not.
To sum up, token-based authentication can verify the user’s identity through authentication token because it contains the relevant information of the authenticated user, which is completely different from session-based authentication before. Therefore, based on this advantage of token, wechat, Alipay, Weibo and GitHub have launched token-based authentication services for accessing open apis and single sign-on. Next, I will focus on OAuth 2.0 and JWT in token-based authentication schemes
2. Two parts: authentication server (authenticating and generating tokens) and authentication resource server (authenticating resources in other services requires verification)
3. First look at the client authorization mode (generally using authorization code mode, in simple terms, you need to redirect the URL authentication server to obtain authorization code (code), in order to obtain access token.
After the first step in the flowchart above, you are redirected to something like
http://localhost:8080/token/oauth/authorize?client_id=client1&response_type=code&redirect_uri=/token
Copy the code
Will return a code in the access
http://localhost:8080/oauth/token?client_id=client1&grant_type=authorization_code&redirect_uri=/token&code=
Copy the code
Append the code value, and access_token is returned
There is another problem: the requested path of the above URL needs to be saved in the database, and a new table needs to be created with fixed fields
CREATE TABLE `oauth_client_details` (
`client_id` varchar(255) NOT NULL,
`resource_ids` varchar(255) DEFAULT NULL,
`client_secret` varchar(255) DEFAULT NULL,
`scope` varchar(255) DEFAULT NULL,
`authorized_grant_types` varchar(255) DEFAULT NULL,
`web_server_redirect_uri` varchar(255) DEFAULT NULL,
`authorities` varchar(255) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL,
`refresh_token_validity` int(11) DEFAULT NULL,
`additional_information` varchar(4096) DEFAULT NULL,
`autoapprove` varchar(255) DEFAULT NULL,
PRIMARY KEY (`client_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Copy the code
The database holds authorized users
CREATE TABLE `sys_user` (
`id` varchar(150) NOT NULL,
`phone` varchar(50) DEFAULT NULL,
`email` varchar(100) DEFAULT NULL,
`password` varchar(150) NOT NULL,
`disable` int(11) NOT NULL,
`create_time` datetime DEFAULT NULL COMMENT 'Creation time',
`update_time` datetime DEFAULT NULL COMMENT 'Update Time',
`ip` varchar(150) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Copy the code
See the following code for details
4. Official code (authentication server)
Pom. The XML file
<dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> < version > 2.3.4. RELEASE < / version > < / dependency > < the dependency > < groupId > org. Springframework. Boot < / groupId > <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2-autoconfigure</artifactId> </dependency>Copy the code
Focused on these three classes, their order of execution: SecurityConfiguration – > MyAuthenticationSuccessHandler – > AuthorizationServerConfiguration
First log in to the user center, obtain the account password, and send the HTTP request
private JSONObject requestToken(String account, String password, String deviceType) {
String result = null;
try {
RestTemplate restTemplate = new RestTemplate();
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
HttpClient httpClient = HttpClientBuilder.create().setRedirectStrategy(new LaxRedirectStrategy()).build();
factory.setHttpClient(httpClient);
restTemplate.setRequestFactory(factory);
MultiValueMap<String, Object> sendMap = new LinkedMultiValueMap<>();
sendMap.add("username", account);
sendMap.add("password", password);
result = RestTemplateUtil.postForEntityFormData(restTemplate, Datas.AUTH_LOGIN_URL, sendMap, deviceType);
logger.info("Result returned by certification center -------》》》》》" + result);
} catch (Exception e) {
logger.error("error", e);
throw new Exception("500", e);
}
return JSON.parseObject(result);
}
Copy the code
Method execution order: UserDetailsService ->protected void configure(HttpSecurity HTTP) – > here will perform in the config myAuthenticationFailureHandler – > (this is the second class)
import javax.transaction.Transactional;
import org.bifu.distributed.auth.constant.AuthContants;
import org.bifu.distributed.auth.dao.UserMapper;
import org.bifu.distributed.auth.domain.User;
import org.bifu.distributed.auth.dto.SecurityUserDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) @EnableWebSecurity public class SecurityConfiguration extends WebSecurityConfigurerAdapter { private final static Logger logger = LoggerFactory.getLogger(SecurityConfiguration.class); @Autowired private UserMapper userMapper; @Autowired private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; The @autowired public void globalUserDetails (AuthenticationManagerBuilder auth) throws the Exception {/ / / / configure the user from database auth.userDetailsService(userDetailsService()).passwordEncoder(new MyPasswordEncoder()); auth.userDetailsService(userDetailsService()).passwordEncoder(new BCryptPasswordEncoder()); // auth.userDetailsService(userDetailsService()).passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder() ); // auth.userDetailsService(userDetailsService()).passwordEncoder(MyPasswordEncoderFactories.createDelegatingPasswordEncoder ()); } /** * authorizeRequests() configures path intercepts, indicating the corresponding permissions, roles, and authentication information for path access. * formLogin() corresponds to the form authentication configuration *logoutHttpBasic () you can configure basic login * @param HTTP * @throws Exception */ @override protected void configure(HttpSecurity http) throws Exception { http.formLogin().loginProcessingUrl("/auth/login").successHandler(myAuthenticationSuccessHandler)
.failureHandler(myAuthenticationFailureHandler).and().csrf().disable().sessionManagement()
.maximumSessions(1).expiredUrl("/expiredSession");
logger.info("Test whether to generate token");
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
@Bean
public UserDetailsService userDetailsService() {
return new UserDetailsService() { @Override @Transactional(rollbackOn = Exception.class) public UserDetails loadUserByUsername(String username) Throws UsernameNotFoundException {/ / (note: the user name of the name attribute must be a username and password of the name attribute must be a password. // logger.info()"Login mobile phone number or email: ======"+username); / / check User User User = userMapper. SelectByPhoneOrEmail (username. The username);if(user == null) { throw new UsernameNotFoundException(AuthContants.USER_NOT_EXIST); } SecurityUserDTO dto = new SecurityUserDTO(); dto.setId(user.getId()); dto.setUsername(username); dto.setPassword(user.getPassword()); dto.setDisable(user.getDisable()); // Create securityUserDTO // securityUserDTO securityUserDTO = new securityUserDTO (user);returndto; }}; }}Copy the code
This is the second class, and it’s going to redirect to get the code
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.bifu.distributed.auth.constant.AuthContants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final static Logger logger = LoggerFactory.getLogger(MyAuthenticationSuccessHandler.class);
@Value(value = "${prefix.auth}")
private String authPrefix; // /token
@Value(value = "${oauth.redirectUrl}")
private String redirectUrl; // /token
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
String deviceType = request.getHeader("deviceType");
logger.info("Access device -----------" " + deviceType);
if (deviceType == null || "".equals(deviceType)) {
deviceType = "browser"; } // Redirect the URL to the /token interfaceif ("browser".equals(deviceType)) {
response.sendRedirect("http://localhost:8080:oauth/authorize? client_id=client1&response_type=code&redirect_uri=/token");
} else if ("app".equals(deviceType)) { response.sendRedirect(http://localhost:8080:oauth/authorize? client_id=client2&response_type=code&redirect_uri=/token); }}}Copy the code
The redirected URL is redirect_uri=/token
5. Before this step, the server needs to generate the public key and key, and each client uses the obtained public key to authenticate the server.
Generate a JKS
keytool -genkeypair -alias kevin_key -keyalg RSA -keypass 123456 -keystore kevin_key.jks -storepass 123456
Copy the code
Export the public key
keytool -list -rfc --keystore kevin_key.jks | openssl x509 -inform pem -pubkey
Copy the code
Save the text public_key.txt
-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxeI6+R6DsGs5RW21Xu1Fur7iPwGjyngN3SCnwPtdR9jTrQ8EIak+gyjpI/g7gIacHIZKMlVFWoEg jQ7+hIQ5FHBrmSR/S81ezCFjYSjBbdrHYQjMRpn4mEWFmQhIyTRhg1Pb5oTUlWx+L3wc45r6JFdMOlgkKBvfo/7lzwGhxeNp10rfoJcnGDhlfZ3PmoIOYmvg 7Z8UwszZpYHWf98164m3hMiPyc81iiy/DEE60OVVepyvynfBwg1aGDyA64w63FZ/2dSwfQ/7VQ7WWJb7oVoIy5pyHslWMuQJPpNCxpOgmb19AgC1GojDSL7W AEq+2gQFrb+7k4PyBdsRYzR9DQIDAQAB -----END PUBLIC KEY-----Copy the code
The authentication server saves JKS in Resource, and the authentication Resource server saves public_key. TXT
import java.util.HashMap; import java.util.Map; import javax.sql.DataSource; import com.alibaba.fastjson.JSONObject; import org.bifu.distributed.auth.constant.AuthContants; import org.bifu.distributed.auth.dto.SecurityUserDTO; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken; import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer; import org.springframework.security.oauth2.provider.ClientDetailsService; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService; import org.springframework.security.oauth2.provider.token.TokenStore; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; import org.springframework.security.oauth2.provider.token.store.JwtTokenStore; import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory; / certification authorization server * * * * * / @ Configuration @ EnableAuthorizationServer public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter { private final static Logger logger = LoggerFactory.getLogger(AuthorizationServerConfiguration.class); @Autowired private AuthenticationManager authenticationManager; @Autowired private DataSource dataSource; @ Override public void the configure (AuthorizationServerEndpointsConfigurer endpoints) throws the Exception {/ / password authentication type endpoints.authenticationManager(this.authenticationManager); endpoints.accessTokenConverter(accessTokenConverter()); Endpoints. TokenStore (tokenStore()); endpoints.reuseRefreshTokens(false);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
oauthServer.tokenKeyAccess("isAnonymous() || hasAuthority('ROLE_TRUSTED_CLIENT')");
oauthServer.checkTokenAccess("hasAuthority('ROLE_TRUSTED_CLIENT')"); } @ Override public void the configure (ClientDetailsServiceConfigurer clients) throws the Exception {/ / JDBC clients.withClientDetails(clientDetails()); } /** * token converter * * @return
*/
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter() {/*** * Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { SecurityUserDTO securityUserDTO = (SecurityUserDTO) authentication.getUserAuthentication().getPrincipal(); logger.info("Override enhanced token method = {}", JSONObject.toJSONString(securityUserDTO));
final Map<String, Object> additionalInformation = new HashMap<>(16);
additionalInformation.put("userId", securityUserDTO.getId());
((DefaultOAuth2AccessToken) accessToken)
.setAdditionalInformation(additionalInformation);
OAuth2AccessToken enhancedToken = super.enhance(accessToken, authentication);
returnenhancedToken; }}; KeyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource())"kevin_key.jks"),
"123456".toCharArray());
accessTokenConverter.setKeyPair(keyStoreKeyFactory.getKeyPair("kevin_key"));
returnaccessTokenConverter; } /** * define clientDetails storage mode - Jdbc mode, inject DataSource ** @return
*/
@Bean
public ClientDetailsService clientDetails() {
return new JdbcClientDetailsService(dataSource);
}
/**
* token store
*
* @param
* @return
*/
@Bean
public TokenStore tokenStore() {
TokenStore tokenStore = new JwtTokenStore(accessTokenConverter());
returntokenStore; }}Copy the code
Now redirect to the /token interface
*/ @responseBody @requestMapping (value =)"/token")
public ResultDTO<TokenResultDTO> token(HttpServletRequest request, HttpServletResponse response,
RedirectAttributes attributes) {
TokenResultDTO result = this.userService.token(attributes, request, response);
logger.info("Obtain token= {}", JSONObject.toJSONString(result));
return new ResultDTO<TokenResultDTO>("200"."succ", result);
}
Copy the code
@data public class TokenResultDTO {private String access_token; private String token_type; private String expires_in; private String scope; private String jti; private String refresh_token; private String userId; }Copy the code
##### The url path is fixed
public TokenResultDTO token(RedirectAttributes attributes, HttpServletRequest request,
HttpServletResponse response) {
try {
String code = request.getParameter("code");
if(StringUtils.isEmpty(code)) { throw new BusinessException(AuthContants.CODE_EXCEPTION); } // Send request token String deviceType ="browser";
if (request.getHeader("deviceType") != null && !"".equals(request.getHeader("deviceType"))) {
deviceType = request.getHeader("deviceType");
}
HttpHeaders headers = new HttpHeaders();
HttpEntity<String> entity = new HttpEntity<String>(headers);
TokenResultDTO tokenResultDTO = null;
if ("browser".equals(deviceType)) {
tokenResultDTO = this.browserRestTemplate.postForObject(
" http://localhost/oauth/token? client_id=client1&grant_type=authorization_code&redirect_uri=/token&code=" + code,
entity, TokenResultDTO.class);
} else if ("app".equals(deviceType)) {
tokenResultDTO = this.appRestTemplate.postForObject(
" http://localhost/oauth/token? client_id=client2&grant_type=authorization_code&redirect_uri=/token&code=" + code,
entity, TokenResultDTO.class);
}
return new TokenResultDTO(tokenResultDTO.getAccess_token(), tokenResultDTO.getRefresh_token(),
tokenResultDTO.getUserId(), tokenResultDTO.getExpires_in());
} catch (BusinessException e) {
logger.error("token?");
throw new Exception("500", e.getMessage());
} catch (Exception e) {
logger.error("token?");
throw new Exception("500", e.getMessage()); }}Copy the code
Here we get the access_token log:
Authentication resource server
Here only explain ResourceServerConfiguration this class, the other is checked exception
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; import org.springframework.security.oauth2.provider.error.OAuth2AuthenticationEntryPoint; import org.springframework.security.oauth2.provider.token.DefaultTokenServices; import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices; import org.springframework.security.oauth2.provider.token.TokenStore; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; import org.springframework.security.oauth2.provider.token.store.JwtTokenStore; import org.springframework.security.web.AuthenticationEntryPoint; / * * * certificate authority resources end * * * * / @ @ the author rs Configuration @ EnableResourceServer public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter { @Autowired private MyAccessDeniedHandler myAccessDeniedHandler; @Autowired private Auth2ResponseExceptionTranslator auth2ResponseExceptionTranslator; @Autowired private SecurityAuthenticationEntryPoint securityAuthenticationEntryPoint; @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.resourceId("client1"); resources.tokenServices(defaultTokenServices()); / / define abnormal conversion effect AuthenticationEntryPoint AuthenticationEntryPoint = new OAuth2AuthenticationEntryPoint (); ((OAuth2AuthenticationEntryPoint) authenticationEntryPoint) .setExceptionTranslator(this.auth2ResponseExceptionTranslator); resources.authenticationEntryPoint(authenticationEntryPoint); } @override public void configure(HttpSecurity HTTP) throws Exception {// http.csrf().disable().exceptionHandling().authenticationEntryPoint(this.securityAuthenticationEntryPoint) .accessDeniedHandler(myAccessDeniedHandler).and().authorizeRequests() .antMatchers("/swagger-resources/**"."/v2/**"."/swagger/**"."/swagger**"."/webjars/**"."/aide/**"."/backstage/**"."/coin/**"."/talla/**"."/asset/**"."/test/**"."/blockchain/borrow/**"."/back/**") .permitAll().anyRequest().authenticated().and().httpBasic().disable(); // Ifream cross-domain Settings http.headers().frameoptions ().sameOrigin(); } / / = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = the following code is consistent with the authentication server = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = / * * * Token storage, here use JWT mode storage * * @param * @return
*/
@Bean
public TokenStore tokenStore() {
TokenStore tokenStore = new JwtTokenStore(accessTokenConverter());
returntokenStore; } /** * create a default resource service token ** @return
*/
@Bean
public ResourceServerTokenServices defaultTokenServices() {
final DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenEnhancer(accessTokenConverter());
defaultTokenServices.setTokenStore(tokenStore());
returndefaultTokenServices; } /** * Token converters must be consistent with authentication services ** @return
*/
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter() {}; Resource resource = new ClassPathResource("public_key.txt");
String publicKey = null;
try {
publicKey = inputStream2String(resource.getInputStream());
} catch (final IOException e) {
throw new RuntimeException(e);
}
accessTokenConverter.setVerifierKey(publicKey);
returnaccessTokenConverter; } / / = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = the code above is consistent with the authentication server = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = private String inputStream2String(InputStream is) throws IOException { BufferedReaderin = new BufferedReader(new InputStreamReader(is));
StringBuffer buffer = new StringBuffer();
String line = "";
while((line = in.readLine()) ! = null) { buffer.append(line); }returnbuffer.toString(); }}Copy the code
Public void configure(HttpSecurity HTTP) public void configure(HttpSecurity HTTP) public void configure(HttpSecurity HTTP)
Personal understanding, welcome to correct communication oh!! The code will then be published in Gitee: gitee.com/ran_song