A preface.

Welcome everyone to join the open source project exchange group, participate in the development of open source projects ~

github.com/hxrui/youla…

Online address: www.youlai.tech

Previous series of articles

Background micro services

  1. Spring Cloud of actual combat | first article: Windows setup Nacos services
  2. The second Spring Cloud of actual combat | : Spring Cloud integration Nacos registry
  3. Spring Cloud of actual combat | article 3: the Spring Cloud integration Nacos configuration center
  4. Spring Cloud of actual combat | article 4: Spring Cloud integration Gateway API Gateway
  5. Spring Cloud of actual combat | article 5: Spring Cloud integration OpenFeign between micro service calls
  6. Spring Cloud of actual combat | article 6: the Spring Cloud + Spring Security Gateway OAuth2 + JWT micro service unified certificate authority
  7. Spring Cloud of actual combat | the seven articles: Spring Cloud + Spring Security Gateway OAuth2 integrated unified certificate authority platform to realize the logout disable JWT scheme
  8. Spring Cloud of actual combat | the eight: Spring Cloud + Spring Security OAuth2 + Vue no refresh perception to realize the separation mode before and after JWT renewed
  9. Spring Cloud of actual combat | the nine article: Spring Security OAuth2 authentication server unified authentication custom exception handling
  10. Spring Cloud of actual combat | article 10: Spring Cloud + Nacos integration Seata 1.4.1 the latest version of the micro service architecture to realize the distributed transaction, advanced road must move beyond the threshold
  11. Spring Cloud of actual combat | article 11: Spring Cloud Gateway Gateway for RESTful interface access and the power button fine-grained control

Background management front end

  1. Vue – element – admin combat | first article: remove the mock to detail the service interface, build SpringCloud + vue front end separation management platform
  2. Vue – element – the second admin combat | : minimal change access backend implementation according to the dynamic loading menu

Wechat applets

  1. Vue + uni – app store actual combat | first article: from 0 to 1 fast development a mall WeChat applet, seamless access Spring Cloud a key to achieve OAuth2 authorization to log in

Application deployment

  1. Docker combat | first article: Linux installation Docker
  2. The second Docker combat | : Docker deployment nacos – server: 1.4.0
  3. Docker combat | article 3: the IDEA of integration Docker plug-in a key to achieve automatic packaging deployment service project, once and for all the technology is worth a try
  4. Docker combat | article 4: Docker to install Nginx, implementation is based on the vue – element – admin deployment project to build the framework of line
  5. Docker combat | article 5: Docker enable TLS encryption solution to expose security vulnerabilities caused by port 2375, hacked three lessons learned of the cloud hosts

Ii. Relationship between OAuth2 and JWT

1. What is OAuth2?

OAUth2 is a set of widely popular authentication and authorization protocol, it has two core roles in this protocol, authentication server and resource server.

The corresponding relationship between the two roles and youlai-Mall module is as follows:

The name of the module Youlai – mall module OAuth2 role Service address
Certification center youlai-auth Authentication server localhost:8000
The gateway youlai-gateway Resource server localhost:9999

The user cannot access the resource server (gateway) directly, but must go to the authentication server for authentication first. After passing the authentication, a token is issued to you. You can pass the authentication only when you access the resource server with the token.

This model I believe that often to party A’s father’s place to do the small partner in the field have a deep experience, the general family can not give you a formal employee card, or take the ID card mortgage for a temporary visit card, the next day will be invalid, so that people have a sense of security ~

Why can the gateway be used as a “resource server”? Gateway serves as the unified entrance of each micro-service (membership service, commodity service, order service, etc.), which is also the unified facade of these resource services. JWT visa check, JWT validity period judgment, and JWT carrying role permission judgment can be determined here.

2. What is JWT?

JWT(JSON Web Token) is a special Token. It is stateless because it can carry user information (user ID, user name, user role set, etc.). Let’s take a look at what the JWT looks like after parsing.

The JWT character string consists of Header, Payload, and Signature.

Header: A JSON object that describes the JWT metadata. The ALG attribute represents the Signature algorithm. The TYP identifier identifies the token type Payload. The authentication server uses the private key to sign the Header and Payload, and the resource server uses the public key to check the signature to prevent data from being tampered withCopy the code

Compared with traditional Cookie/Session Session management, JWT has many advantages, because Cookie/Session needs to store user information in the server Session, and then obtain user information with the SessionId stored in the client Cookie. This process consumes server memory and has strict requirements on clients (Cookie support is required). The biggest feature of JWT is stateless and decentralized, so JWT is more suitable for distributed scenarios and does not need to perform session synchronization on multiple servers, which consumes server performance.

In addition, JWT and Redis+Token are two kinds of session management. JWT and Redis+Token are two kinds of session management. JWT and Redis+Token are two kinds of session management.

3. Relationship between OAuth2 and JWT?

  • OAuth2 is an authentication and authorization protocol specification.
  • JWT is the implementation of token-based security authentication protocol.

Tokens issued by OAuth2’s authentication server can be implemented using JWT, which is lightweight and secure.

Authentication server

Authentication server youlai landing – mall youlai – auth certification center module, complete code address: making | yards cloud

1. The pom

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-jose</artifactId>
    </dependency>
Copy the code

2. Certification service configuration (AuthorizationServerConfig)

/** * Authentication service configuration */
@Configuration
@EnableAuthorizationServer
@AllArgsConstructor
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    private DataSource dataSource;
    private AuthenticationManager authenticationManager;
    private UserDetailsServiceImpl userDetailsService;

    /** * Client information configuration */
    @Override
    @SneakyThrows
    public void configure(ClientDetailsServiceConfigurer clients) {
        JdbcClientDetailsServiceImpl jdbcClientDetailsService = new JdbcClientDetailsServiceImpl(dataSource);
        jdbcClientDetailsService.setFindClientDetailsSql(AuthConstants.FIND_CLIENT_DETAILS_SQL);
        jdbcClientDetailsService.setSelectClientDetailsSql(AuthConstants.SELECT_CLIENT_DETAILS_SQL);
        clients.withClientDetails(jdbcClientDetailsService);
    }

    /** * Configure authorization and token access endpoints and token services */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> tokenEnhancers = new ArrayList<>();
        tokenEnhancers.add(tokenEnhancer());
        tokenEnhancers.add(jwtAccessTokenConverter());
        tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);

        endpoints.authenticationManager(authenticationManager)
                .accessTokenConverter(jwtAccessTokenConverter())
                .tokenEnhancer(tokenEnhancerChain)
                .userDetailsService(userDetailsService)
                // Refresh_token can be used in two ways: repeatable (true) and non-repeatable (false). The default value is true
                // 1. Reuse: When the access_token expiration is refreshed, the refresh token expiration time remains unchanged and is based on the time when the access_token is generated for the first time
                // 2. Non-repeated use: When the Access_token expires, the refresh_token expiration time continues. The refresh_token expiration time is refreshed within the validity period without the need to log in again
                .reuseRefreshTokens(false);
    }

    /** * allows form authentication */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) {
        security.allowFormAuthenticationForClients();
    }

    /** * Use an asymmetric encryption algorithm to sign the token */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter(a) {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(keyPair());
        return converter;
    }

    /** * Get the key pair (public key + private key) from the keystore in classpath */
    @Bean
    public KeyPair keyPair(a) {
        KeyStoreKeyFactory factory = new KeyStoreKeyFactory(
                new ClassPathResource("youlai.jks"), "123456".toCharArray());
        KeyPair keyPair = factory.getKeyPair(
                "youlai"."123456".toCharArray());
        return keyPair;
    }

    /** * JWT content enhancement */
    @Bean
    public TokenEnhancer tokenEnhancer(a) {
        return (accessToken, authentication) -> {
            Map<String, Object> map = new HashMap<>(2);
            User user = (User) authentication.getUserAuthentication().getPrincipal();
            map.put(AuthConstants.JWT_USER_ID_KEY, user.getId());
            map.put(AuthConstants.JWT_CLIENT_ID_KEY, user.getClientId());
            ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);
            returnaccessToken; }; }}Copy the code

AuthorizationServerConfig this configuration is the core of the certification service implementation class. This concludes with two key points, client information configuration and access_token generation configuration.

2.1 Client Information Configuration

Configure OAuth2 authentication to allow access to the client information, because access OAuth2 authentication server first people have to recognize you this client bar, such as the case above QQ OAuth2 authentication server recognized “Youdao Cloud note” client.

Similarly, we need to configure the client information on the authentication server to represent the clients approved by the authentication server. It is generally configurable in the authentication server’s memory, but this is inconvenient to manage extensions. Therefore, it is better to configure it in the database to provide a visual interface for its management, so as to facilitate the flexible access of multiple terminals such as PC, APP and small program.

Spring Security OAuth2 provides the oauth_client_details client information table

CREATE TABLE `oauth_client_details`  (
  `client_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `resource_ids` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `client_secret` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `scope` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `authorized_grant_types` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `web_server_redirect_uri` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `authorities` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `access_token_validity` int(11) NULL DEFAULT NULL,
  `refresh_token_validity` int(11) NULL DEFAULT NULL,
  `additional_information` varchar(4096) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `autoapprove` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL.PRIMARY KEY (`client_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
Copy the code

Example Add a client message

INSERT INTO `oauth_client_details` VALUES ('client'.NULL.'123456'.'all'.'password,refresh_token'.' '.NULL.NULL.NULL.NULL.NULL);
Copy the code

2.2 Token Generation Configuration

The project uses JWT to implement access_token. The configuration of the access_token generation step is as follows:

1. Generate a key store

Use the KEYtool of the JDK tool to generate the JKS Key Store (Java Key Store) and place Youlai.jks in the Resources directory

keytool -genkey -alias youlai -keyalg RSA -keypass 123456 -keystore youlai.jks -storepass 123456

-genkey generate key -alias alias -keyalg key algorithm -keypass key password -keystore store path and name of the generated keystore -storepass keystore passwordCopy the code

2. JWT content enhancement

JWT payload information is fixed by default, and if you want to customize some additional information, you need to implement TokenEnhancer’s Enhance method to add additional information to the Access_token.

3. JWT’s signature

JwtAccessTokenConverter is a token generation converter that can specify the token generation method (JWT) and sign the JWT.

A Signature actually generates an identifier (the Signature part of the JWT) that the recipient can use to verify that the information has been tampered with. For the principle part, please refer to this article: principle and method of RSA encryption, decryption, signature and signature check

There are two ways of JWT signature:

Symmetric mode: The authentication server and resource server use the same key to add and check signatures. The default algorithm is HMAC

Asymmetric mode: The authentication server uses the private key to add signatures, and the resource server uses the public key to check signatures. The default algorithm is RSA

Asymmetric is more secure than symmetric because the private key is known only to the authentication server.

RSA asymmetric signature is used in the project. The implementation steps are as follows:

Obtain the key pair (key + private key) from the keystore (2). Authenticate server private key pair Token signature (3). Provides an interface for obtaining public keys for resource server verificationCopy the code

Public Key Acquisition Interface

/** * RSA public key open interface */ @restController@allargsconstructor public class PublicKeyController {private KeyPair KeyPair; @GetMapping("/getPublicKey") public Map<String, Object> getPublicKey() { RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAKey key = new RSAKey.Builder(publicKey).build(); return new JWKSet(key).toJSONObject(); }}Copy the code

3. Security Configuration (WebSecurityConfig)

@Configuration @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests().requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() .and() .authorizeRequests().antMatchers("/getPublicKey").permitAll().anyRequest().authenticated() .and() .csrf().disable(); } /** * If SpringBoot is not configured, an AuthenticationManager is automatically configured, overwriting the user */ @bean public AuthenticationManager in memory authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Bean public PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); }}Copy the code

Security configuration mainly involves configuring request access permission, defining authentication manager, and password encryption configuration.

4. Resource server

Resource server youlai landing – mall youlai – gateway service gateway module, complete code address: making | yards cloud

As mentioned above, gateway plays the role of resource server. Because gateway is the unified entrance to access micro-service resources, it is most appropriate to conduct unified authentication of resource access here.

1. The pom

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-jose</artifactId>
    </dependency>
Copy the code

2. Configuration file (Youlai-gateway.yaml)

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          JWT public key request path
          jwk-set-uri: 'http://localhost:8000/getPublicKey'
  redis:
    database: 0
    host: localhost
    port: 6379
    password:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true Enable service discovery
          lower-case-service-id: true
      routes:
        - id: youlai-auth
          uri: lb://youlai-auth
          predicates:
            - Path=/youlai-auth/**
          filters:
            - StripPrefix=1
        - id: youlai-admin
          uri: lb://youlai-admin
          predicates:
            - Path=/youlai-admin/**
          filters:
            - StripPrefix=1

Configure a whitelist path
white-list:
    urls:
      - "/youlai-auth/oauth/token"
Copy the code

3. Authentication Manager

The authentication manager is the adjudicator that verifies whether the resource server has the right to access the resource. The functions of the core part of the authentication manager have been explained in the form of annotations and then added to the specific form.

/** * Authentication manager */
@Component
@AllArgsConstructor
@Slf4j
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {

    private RedisTemplate redisTemplate;
    private WhiteListConfig whiteListConfig;

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
        ServerHttpRequest request = authorizationContext.getExchange().getRequest();
        String path = request.getURI().getPath();
        PathMatcher pathMatcher = new AntPathMatcher();
        
        // 1. Directly approve the cross-domain precheck request
        if (request.getMethod() == HttpMethod.OPTIONS) {
            return Mono.just(new AuthorizationDecision(true));
        }

        // 2. The token is empty and the access is denied
        String token = request.getHeaders().getFirst(AuthConstants.JWT_TOKEN_HEADER);
        if (StrUtil.isBlank(token)) {
            return Mono.just(new AuthorizationDecision(false));
        }

        // 3. List of roles with cache access permission
        Map<Object, Object> resourceRolesMap = redisTemplate.opsForHash().entries(AuthConstants.RESOURCE_ROLES_KEY);
        Iterator<Object> iterator = resourceRolesMap.keySet().iterator();

        // 4. Request path matching resource need role authority set authorities
        List<String> authorities = new ArrayList<>();
        while (iterator.hasNext()) {
            String pattern = (String) iterator.next();
            if (pathMatcher.match(pattern, path)) {
                authorities.addAll(Convert.toList(String.class, resourceRolesMap.get(pattern)));
            }
        }
        Mono<AuthorizationDecision> authorizationDecisionMono = mono
                .filter(Authentication::isAuthenticated)
                .flatMapIterable(Authentication::getAuthorities)
                .map(GrantedAuthority::getAuthority)
                .any(roleId -> {
                    // 4. RoleId = role_{roleId}; role_{roleId}
                    log.info("Access path: {}", path);
                    log.info("RoleId: {}", roleId);
                    log.info("Resource Required Authorities: {}", authorities);
                    return authorities.contains(roleId);
                })
                .map(AuthorizationDecision::new)
                .defaultIfEmpty(new AuthorizationDecision(false));
        returnauthorizationDecisionMono; }}Copy the code

The first and second parts are just some basic interview judgments without too much explanation

The third place gets the resource permission data from the Redis cache. First, we will focus on two issues:

A. What is the format of resource permission data? B. When is the data initialized into the cache?Copy the code

With these two questions in mind, let’s analyze what needs to be done in advance to complete step 4 to retrieve the resource permission data from the cache.

A. Format of resource permission data

Redis cache the mapping between URL and ROLE_IDS in redis.

B. Initialize the cache timing

SpringBoot provides two interfaces CommandLineRunner and ApplicationRunner to perform some business logic after the container is started, such as data initialization and preloading, and MQ listening startup. The execution timing of the two interfaces is the same. The only difference is that interface parameters are different. If you are interested, you can get to know these two friends and see them often in the future

The business logic here is to load the resource permissions data from MySQL into the Redis cache after the container is initialized.

Redis cache resource permission data

From the cache data, you can view the role that has the permission to access the resource URL. Obtain the value from the cache and assign it to resourceRolesMap.

After matching the url of resourceRolesMap (Ant Path matching rule), add the role information required for the resource to authorities.

After the user’s role matches any one of the authorities, the user has the authority to access the resources.

4. Configure the resource server

The work here is to configure the authentication manager AuthorizationManager to the resource server, request whitelist release, unauthorized access, and custom exception responses for invalid tokens. Configuration classes are largely generic, with core functions and attention to detail explained through comments.

/** * Resource server Configuration */ @allargsconstructor @configuration // Annotation needs to use @enableWebFluxSecurity instead of @enableWebSecurity because of SpringCloud Gateway Based on WebFlux @enableWebFluxSecurity Public class ResourceServerConfig {private AuthorizationManager authorizationManager; private CustomServerAccessDeniedHandler customServerAccessDeniedHandler; private CustomServerAuthenticationEntryPoint customServerAuthenticationEntryPoint; private WhiteListConfig whiteListConfig; @Bean public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { http.oauth2ResourceServer().jwt() .jwtAuthenticationConverter(jwtAuthenticationConverter()); / / custom processing JWT HTTP request header expired or signature the wrong results. Oauth2ResourceServer () authenticationEntryPoint (customServerAuthenticationEntryPoint); http.authorizeExchange() .pathMatchers(ArrayUtil.toArray(whiteListConfig.getUrls(),String.class)).permitAll() .anyExchange().access(authorizationManager) .and() .exceptionHandling() . AccessDeniedHandler (customServerAccessDeniedHandler) unauthorized / / processing . AuthenticationEntryPoint (customServerAuthenticationEntryPoint). / / processing of unauthorized and (). CSRF (). The disable (); return http.build(); } /** * @linkhttps://blog.csdn.net/qq_24230139/article/details/105091273 * ServerHttpSecurity does not treat the payload of AUTHORITIES in JWT as Authentication * Redefine ReactiveAuthenticationManager permissions manager, The default Converter JwtGrantedAuthoritiesConverter * / @ Bean public Converter < Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstants.AUTHORITY_PREFIX); jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstants.AUTHORITY_CLAIM_NAME); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter); return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter); }}Copy the code
/** * has no access to the custom response */
@Component
public class CustomServerAccessDeniedHandler implements ServerAccessDeniedHandler {

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException e) {
        ServerHttpResponse response=exchange.getResponse();
        response.setStatusCode(HttpStatus.OK);
        response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        response.getHeaders().set("Access-Control-Allow-Origin"."*");
        response.getHeaders().set("Cache-Control"."no-cache");
        String body= JSONUtil.toJsonStr(Result.custom(ResultCodeEnum.USER_ACCESS_UNAUTHORIZED));
        DataBuffer buffer =  response.bufferFactory().wrap(body.getBytes(Charset.forName("UTF-8")));
        returnresponse.writeWith(Mono.just(buffer)); }}Copy the code
/** * Invalid token/ Token expired Custom response */
@Component
public class CustomServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {

    @Override
    public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException e) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.OK);
        response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        response.getHeaders().set("Access-Control-Allow-Origin"."*");
        response.getHeaders().set("Cache-Control"."no-cache");
        String body = JSONUtil.toJsonStr(Result.custom(ResultCodeEnum.USER_ACCOUNT_UNAUTHENTICATED));
        DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(Charset.forName("UTF-8")));
        returnresponse.writeWith(Mono.just(buffer)); }}Copy the code

5. Network authentication test

The simulation data shows that the admin user has role 2, and role 2 has menu, user, and department management rights, but has no other rights

The user Character ID Character name
admin 2 System administrator
The name of the resource Resource path Requiring Role Rights
System management /youlai-admin/** [1]
Menu management /youlai-admin/menus/** [1, 2]
User management /youlai-admin/users/** [1, 2]
Department of management /youlai-admin/depts/** [1, 2]
The dictionary management /youlai-admin/dictionaries/** [1]
Role management /youlai-admin/roles/** [1]
Resource management /youlai-admin/resources/** [1]

Start the front-end engineering management platform youlai mall – admin complete code address: making | yards cloud

In addition to the menu management, user management, and department management resources that the system administrator has access permission to, access is not authorized is displayed on the page, which directly indicates that the gateway server achieves the purpose of request authentication.

Five epilogue.

At this point, unified authentication and authorization for Spring Cloud is implemented. In fact, there are many points that can be extended. In this article, the client information is stored in the database, so that you can add a management interface to maintain the client information, so that you can flexibly configure the client access to the authentication platform, authentication period, and so on. There is also unfinished business. We know that JWT is stateless, so how can users invalidate JWT when logging out, changing passwords, or logging out? It is not possible to delete user information from the server like cookie/session. So these are all things worth thinking about, and I’ll offer solutions in the next article.

Today, I have been working in blog park for 6 years, and I still haven’t done anything in the past 6 years. The pressure of work and life is quite big, but I don’t want to give up like this, so… Come on!!

If you have any good suggestions for this article or project, please leave a comment, thanks ~