SpringCloud microservice practice -- build an enterprise level development framework: Gateway+OAuth2+JWT to realize unified authentication and authorization of microservices

   OAuth2 is an open standard on authorization. The core idea is to authenticate the user's identity through various authentication means (which means OAuth2 doesn't care about) and issue a token so that third-party applications can use the token to access specified resources within a limited time and range.
   in OAuth2, the token is used to verify the user login legitimacy, but the biggest problem with the token is that it does not carry user information, and the resource server cannot verify locally. Each time for resource access, the resource server needs to send a request to the authentication server, one is to verify the validity of the token, and the other is to obtain the user information corresponding to the token. If there are a large number of such requests, the processing efficiency is undoubtedly very low, and the authentication server will become a central node, which will affect the performance in the distributed architecture. If the authentication server issues a token in jwt format, the resource server can directly verify the validity of the token and bind users, which undoubtedly greatly improves the processing efficiency and reduces single point hidden dangers.
   SpringCloud authentication and authorization solution: the authentication service is responsible for authentication, the gateway is responsible for verification, authentication and authentication, and other API services are responsible for processing their own business logic. Security related logic only exists in authentication services and gateway services. Other services only provide services without any security related logic.
Micro service authentication function division:

  • Gitegg OAuth: Oauth2 user authentication and single sign on
  • Gitegg gateway: request forwarding and unified authentication
  • Gitegg system: read the RBAC permission configuration of the system configuration and store it in the cache

1, Authentication configuration

1. gitegg-platform-oauth2 project is newly built under gitegg platform project for unified management of OAuth2 version and unified configuration

<!--?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">
    <parent>
        <artifactid>GitEgg-Platform</artifactid>
        <groupid>com.gitegg.platform</groupid>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelversion>4.0.0</modelversion>

    <artifactid>gitegg-platform-oauth2</artifactid>
    <name>${project.artifactId}</name>
    <packaging>jar</packaging>

    <dependencies>
        <dependency>
            <groupid>org.springframework.boot</groupid>
            <artifactid>spring-boot-configuration-processor</artifactid>
        </dependency>
        <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>
        <dependency>
            <groupid>org.springframework.security</groupid>
            <artifactid>spring-security-oauth2-resource-server</artifactid>
        </dependency>
        <dependency>
            <groupid>com.gitegg.platform</groupid>
            <artifactid>gitegg-platform-swagger</artifactid>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

2. Introducing the required libraries into gitegg OAuth project

<!--?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">
    <parent>
        <artifactid>GitEgg-Cloud</artifactid>
        <groupid>com.gitegg.cloud</groupid>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelversion>4.0.0</modelversion>

    <artifactid>gitegg-oauth</artifactid>
    <name>${project.artifactId}</name>
    <packaging>jar</packaging>

    <dependencies>
        <!-- gitegg-platform-boot -->
        <dependency>
            <groupid>com.gitegg.platform</groupid>
            <artifactid>gitegg-platform-boot</artifactid>
            <version>${gitegg.project.version}</version>
        </dependency>
        <!-- gitegg-platform-cloud -->
        <dependency>
            <groupid>com.gitegg.platform</groupid>
            <artifactid>gitegg-platform-cloud</artifactid>
            <version>${gitegg.project.version}</version>
        </dependency>
        <!-- gitegg-platform-oauth2 -->
        <dependency>
            <groupid>com.gitegg.platform</groupid>
            <artifactid>gitegg-platform-oauth2</artifactid>
            <version>${gitegg.project.version}</version>
        </dependency>
        <!-- gitegg Database driver and connection pool -->
        <dependency>
            <groupid>com.gitegg.platform</groupid>
            <artifactid>gitegg-platform-db</artifactid>
        </dependency>
        <!-- gitegg mybatis-plus -->
        <dependency>
            <groupid>com.gitegg.platform</groupid>
            <artifactid>gitegg-platform-mybatis</artifactid>
        </dependency>
        <!-- Verification Code -->
        <dependency>
            <groupid>com.gitegg.platform</groupid>
            <artifactid>gitegg-platform-captcha</artifactid>
        </dependency>
        <!-- gitegg-service-system of fegin Public call method -->
        <dependency>
            <groupid>com.gitegg.cloud</groupid>
            <artifactid>gitegg-service-system-api</artifactid>
            <version>${gitegg.project.version}</version>
        </dependency>
        <dependency>
            <groupid>org.apache.tomcat.embed</groupid>
            <artifactid>tomcat-embed-core</artifactid>
        </dependency>
        <dependency>
            <groupid>org.springframework.boot</groupid>
            <artifactid>spring-boot-starter-data-redis</artifactid>
        </dependency>
    </dependencies>

</project>

3. JWT can use HMAC algorithm or RSA public / private key pair to sign to prevent tampering. First, we use the keytool to generate the RSA certificate gitegg.jks, copy it to the resource directory of the gitegg OAuth project, enter the CMD command line into the bin directory of the JDK installation directory, and use the keytool command to generate the gitegg.jks certificate

keytool -genkey -alias gitegg -keyalg RSA -keystore gitegg.jks

4. Create GitEggUserDetailsServiceImpl.java to implement the spring security interface for obtaining user information, which is used to obtain user information during spring security authentication

package com.gitegg.oauth.service;

import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.common.exceptions.UserDeniedAuthorizationException;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import com.gitegg.oauth.enums.AuthEnum;
import com.gitegg.platform.base.constant.AuthConstant;
import com.gitegg.platform.base.domain.GitEggUser;
import com.gitegg.platform.base.enums.ResultCodeEnum;
import com.gitegg.platform.base.result.Result;
import com.gitegg.service.system.api.feign.IUserFeign;

import cn.hutool.core.bean.BeanUtil;
import lombok.RequiredArgsConstructor;

/**
 *  Implement the interface for spring security to obtain user information
 *
 * @author gitegg
 */
@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class GitEggUserDetailsServiceImpl implements UserDetailsService {

    private final IUserFeign userFeign;

    private final HttpServletRequest request;

    @Override
    public GitEggUserDetails loadUserByUsername(String username) {

        // Get login type, password, QR code and verification code
        String authLoginType = request.getParameter(AuthConstant.AUTH_TYPE);

        // Get client id
        String clientId = request.getParameter(AuthConstant.AUTH_CLIENT_ID);

        // Remote call return data
        Result<object> result;

        // Log in via mobile number
        if (!StringUtils.isEmpty(authLoginType) && AuthEnum.PHONE.code.equals(authLoginType))
        {
            String phone = request.getParameter(AuthConstant.PHONE_NUMBER);
            result = userFeign.queryUserByPhone(phone);
        }
        // Log in with account and password
        else if(!StringUtils.isEmpty(authLoginType) && AuthEnum.QR.code.equals(authLoginType))
        {
            result = userFeign.queryUserByAccount(username);
        }
        else
        {
            result = userFeign.queryUserByAccount(username);
        }

        // Judgment return information
        if (null != result && result.isSuccess()) {
            GitEggUser gitEggUser = new GitEggUser();
            BeanUtil.copyProperties(result.getData(), gitEggUser, false);
            if (gitEggUser == null || gitEggUser.getId() == null) {
                throw new UsernameNotFoundException(ResultCodeEnum.INVALID_USERNAME.msg);
            }

            if (CollectionUtils.isEmpty(gitEggUser.getRoleIdList())) {
                throw new UserDeniedAuthorizationException(ResultCodeEnum.INVALID_ROLE.msg);
            }

            return new GitEggUserDetails(gitEggUser.getId(), gitEggUser.getTenantId(), gitEggUser.getOauthId(),
                gitEggUser.getNickname(), gitEggUser.getRealName(), gitEggUser.getOrganizationId(),
                gitEggUser.getOrganizationName(),
                    gitEggUser.getOrganizationIds(), gitEggUser.getOrganizationNames(), gitEggUser.getRoleId(), gitEggUser.getRoleIds(), gitEggUser.getRoleName(), gitEggUser.getRoleNames(),
                gitEggUser.getRoleIdList(), gitEggUser.getRoleKeyList(), gitEggUser.getResourceKeyList(),
                gitEggUser.getDataPermission(),
                gitEggUser.getAvatar(), gitEggUser.getAccount(), gitEggUser.getPassword(), true, true, true, true,
                AuthorityUtils.createAuthorityList(gitEggUser.getRoleIdList().toArray(new String[gitEggUser.getRoleIdList().size()])));
        } else {
            throw new UsernameNotFoundException(result.getMsg());
        }
    }

}

5. Create a new AuthorizationServerConfig.java for authentication service related configuration. Please remember to modify the password of gitegg.jks configuration in the formal environment. The default here is 123456. TokenEnhancer is the extended information of the login user, which can be defined by yourself.

package com.gitegg.oauth.config;

import java.security.KeyPair;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
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.TokenGranter;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;

import com.anji.captcha.service.CaptchaService;
import com.gitegg.oauth.granter.GitEggTokenGranter;
import com.gitegg.oauth.service.GitEggClientDetailsServiceImpl;
import com.gitegg.oauth.service.GitEggUserDetails;
import com.gitegg.platform.base.constant.AuthConstant;
import com.gitegg.platform.base.constant.TokenConstant;
import com.gitegg.service.system.api.feign.IUserFeign;

import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;

/**
 * Authentication service configuration
 */
@Configuration
@EnableAuthorizationServer
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    private final DataSource dataSource;

    private final AuthenticationManager authenticationManager;

    private final UserDetailsService userDetailsService;

    private final IUserFeign userFeign;

    private final RedisTemplate redisTemplate;

    private final CaptchaService captchaService;

    @Value("${captcha.type}")
    private String captchaType;

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

    /**
     * Configure authorization, access endpoint of token 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);

        // Get custom tokenGranter
        TokenGranter tokenGranter = GitEggTokenGranter.getTokenGranter(authenticationManager, endpoints, redisTemplate,
            userFeign, captchaService, captchaType);

        endpoints.authenticationManager(authenticationManager)
                .accessTokenConverter(jwtAccessTokenConverter())
                .tokenEnhancer(tokenEnhancerChain)
                .userDetailsService(userDetailsService)
            .tokenGranter(tokenGranter)
                /**
                 *
                 * refresh_token There are two usage methods: reuse (true) and non reuse (false). The default is true
                 * 1.Reuse: access_ When the token expires and is refreshed, the expiration time of the refresh token remains unchanged, and the time of the first generation still prevails
                 * 2.Non reusable: access_ Refresh when the token expires_ The token expiration time continues in refresh_ The token is refreshed within the validity period without logging in again
                 */
                .reuseRefreshTokens(false);
    }

    /**
     * Allow form authentication
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) {
        security.allowFormAuthenticationForClients()
                .tokenKeyAccess("permitAll()")
                .checkTokenAccess("isAuthenticated()");
    }

    /**
     * Sign the token using asymmetric encryption algorithm
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(keyPair());
        return converter;
    }

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

    /**
     * JWT Content enhancement
     */
    @Bean
    public TokenEnhancer tokenEnhancer() {
        return (accessToken, authentication) -> {
            Map<string, object=""> map = new HashMap<>(2);
            GitEggUserDetails user = (GitEggUserDetails) authentication.getUserAuthentication().getPrincipal();
            map.put(TokenConstant.TENANT_ID, user.getTenantId());
            map.put(TokenConstant.OAUTH_ID, user.getOauthId());
            map.put(TokenConstant.USER_ID, user.getId());
            map.put(TokenConstant.ORGANIZATION_ID, user.getOrganizationId());
            map.put(TokenConstant.ORGANIZATION_NAME, user.getOrganizationName());
            map.put(TokenConstant.ORGANIZATION_IDS, user.getOrganizationIds());
            map.put(TokenConstant.ORGANIZATION_NAMES, user.getOrganizationNames());
            map.put(TokenConstant.ROLE_ID, user.getRoleId());
            map.put(TokenConstant.ROLE_NAME, user.getRoleName());
            map.put(TokenConstant.ROLE_IDS, user.getRoleIds());
            map.put(TokenConstant.ROLE_NAMES, user.getRoleNames());
            map.put(TokenConstant.ACCOUNT, user.getAccount());
            map.put(TokenConstant.REAL_NAME, user.getRealName());
            map.put(TokenConstant.NICK_NAME, user.getNickname());
            map.put(TokenConstant.ROLE_ID_LIST, user.getRoleIdList());
            map.put(TokenConstant.ROLE_KEY_LIST, user.getRoleKeyList());
            //If you don't put the permission menu in jwt, the length of jwt will be uncontrollable when there are too many menus
//            map.put(TokenConstant.RESOURCE_KEY_LIST, user.getResourceKeyList());
            map.put(TokenConstant.DATA_PERMISSION, user.getDataPermission());
            map.put(TokenConstant.AVATAR, user.getAvatar());
            ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);
            return accessToken;
        };
    }
}


6. The Gateway needs the RSA public key to verify whether the signature is legal when authenticating and authorizing. Therefore, a getKey interface of GitEggOAuthController is newly created here to obtain the RSA public key by the Gateway

    @GetMapping("/public_key")
    public Map<string, object=""> getKey() {
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAKey key = new RSAKey.Builder(publicKey).build();
        return new JWKSet(key).toJSONObject();
    }

7. Create a new ResourceServerConfig.java resource server configuration and release public_ Read permission of key

	@Override
	@SneakyThrows
	public void configure(HttpSecurity http) {
		http.headers().frameOptions().disable();
		http.formLogin()
			.and()
			.authorizeRequests().requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
			.and()
			.authorizeRequests()
			.antMatchers(
				"/oauth/public_key").permitAll()
			.anyRequest().authenticated()
			.and()
			.csrf().disable();
	}

8. Create InitResourceRolesCacheRunner.java in gitegg service system to implement the CommandLineRunner interface, which is used to load RBAC permission configuration information to the cache during system startup

package com.gitegg.service.system.component;

import java.util.*;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import com.gitegg.platform.base.constant.AuthConstant;
import com.gitegg.service.system.entity.Resource;
import com.gitegg.service.system.service.IResourceService;

import cn.hutool.core.collection.CollectionUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
 * Container startup completes loading resource permission data into the cache
 */
@Slf4j
@RequiredArgsConstructor(onConstructor_ = @Autowired)
@Component
public class InitResourceRolesCacheRunner implements CommandLineRunner {

    private final RedisTemplate redisTemplate;

    private final IResourceService resourceService;

    /**
     * Enable tenant mode
     */
    @Value(("${tenant.enable}"))
    private Boolean enable;

    @Override
    public void run(String... args) {

        log.info("InitResourceRolesCacheRunner running");

        // Query the relationship between system roles and permissions
        List<resource> resourceList = resourceService.queryResourceRoleIds();

        // Judge whether the tenant mode is enabled. If it is enabled, the role permissions need to be stored by tenant
        if (enable) {
            Map<long, list<resource="">> resourceListMap =
                resourceList.stream().collect(Collectors.groupingBy(Resource::getTenantId));
            resourceListMap.forEach((key, value) -> {
                String redisKey = AuthConstant.TENANT_RESOURCE_ROLES_KEY + key;
                redisTemplate.delete(redisKey);
                addRoleResource(redisKey, value);
                System.out.println(redisTemplate.opsForHash().entries(redisKey).size());
            });
        } else {
            redisTemplate.delete(AuthConstant.RESOURCE_ROLES_KEY);
            addRoleResource(AuthConstant.RESOURCE_ROLES_KEY, resourceList);
        }
    }

    private void addRoleResource(String key, List<resource> resourceList) {
        Map<string, list<string="">> resourceRolesMap = new TreeMap<>();
        Optional.ofNullable(resourceList).orElse(new ArrayList<>()).forEach(resource -> {
            // roleId -> ROLE_{roleId}
            List<string> roles = Optional.ofNullable(resource.getRoleIds()).orElse(new ArrayList<>()).stream()
                .map(roleId -> AuthConstant.AUTHORITY_PREFIX + roleId).collect(Collectors.toList());
            if (CollectionUtil.isNotEmpty(roles)) {
                resourceRolesMap.put(resource.getResourceUrl(), roles);
            }
        });
        redisTemplate.opsForHash().putAll(key, resourceRolesMap);
    }
}

9. Create a new gateway service gitegg gateway, which is used as the resource service and client service of Oauth2. Forward, uniformly verify, authenticate and authenticate requests to access micro services, and introduce relevant dependencies

<!--?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">
    <parent>
        <artifactid>GitEgg-Cloud</artifactid>
        <groupid>com.gitegg.cloud</groupid>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelversion>4.0.0</modelversion>

    <artifactid>gitegg-gateway</artifactid>

    <dependencies>
        <dependency>
            <groupid>com.gitegg.platform</groupid>
            <artifactid>gitegg-platform-base</artifactid>
            <version>${gitegg.project.version}</version>
        </dependency>
        <!-- Nacos Service registration discovery -->
        <dependency>
            <groupid>com.alibaba.cloud</groupid>
            <artifactid>spring-cloud-starter-alibaba-nacos-discovery</artifactid>
        </dependency>
        <!-- Nacos Distributed configuration -->
        <dependency>
            <groupid>com.alibaba.cloud</groupid>
            <artifactid>spring-cloud-starter-alibaba-nacos-config</artifactid>
        </dependency>
        <!-- OpenFeign Microservice invocation solution -->
        <dependency>
            <groupid>org.springframework.cloud</groupid>
            <artifactid>spring-cloud-starter-openfeign</artifactid>
        </dependency>
        <dependency>
            <groupid>com.gitegg.platform</groupid>
            <artifactid>gitegg-platform-oauth2</artifactid>
            <version>${gitegg.project.version}</version>
        </dependency>
        <!-- gitegg cache Custom extension -->
        <dependency>
            <groupid>com.gitegg.platform</groupid>
            <artifactid>gitegg-platform-cache</artifactid>
            <version>${gitegg.project.version}</version>
        </dependency>
        <dependency>
            <groupid>org.springframework.cloud</groupid>
            <artifactid>spring-cloud-starter-gateway</artifactid>
        </dependency>
        <dependency>
            <groupid>io.springfox</groupid>
            <artifactid>springfox-swagger2</artifactid>
        </dependency>
        <dependency>
            <groupid>com.github.xiaoymin</groupid>
            <artifactid>knife4j-spring-ui</artifactid>
        </dependency>
    </dependencies>

</project>

10. Create AuthResourceServerConfig.java to configure the gateway service. For security configuration, you need to use @ EnableWebFluxSecurity instead of @ EnableWebSecurity, because the spring cloud gateway is based on WebFlux

package com.gitegg.gateway.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
import org.springframework.security.web.server.SecurityWebFilterChain;

import com.gitegg.gateway.auth.AuthorizationManager;
import com.gitegg.gateway.filter.WhiteListRemoveJwtFilter;
import com.gitegg.gateway.handler.AuthServerAccessDeniedHandler;
import com.gitegg.gateway.handler.AuthServerAuthenticationEntryPoint;
import com.gitegg.gateway.props.AuthUrlWhiteListProperties;
import com.gitegg.platform.base.constant.AuthConstant;

import cn.hutool.core.util.ArrayUtil;
import lombok.AllArgsConstructor;
import reactor.core.publisher.Mono;

/**
 * Resource server configuration
 */
@AllArgsConstructor
@Configuration
// Annotations need to use @ EnableWebFluxSecurity instead of @ EnableWebSecurity because the spring cloud gateway is based on WebFlux
@EnableWebFluxSecurity
public class AuthResourceServerConfig {

    private final AuthorizationManager authorizationManager;

    private final AuthServerAccessDeniedHandler authServerAccessDeniedHandler;

    private final AuthServerAuthenticationEntryPoint authServerAuthenticationEntryPoint;

    private final AuthUrlWhiteListProperties authUrlWhiteListProperties;

    private final WhiteListRemoveJwtFilter whiteListRemoveJwtFilter;

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        http.oauth2ResourceServer().jwt()
                .jwtAuthenticationConverter(jwtAuthenticationConverter());
        // Customize the results of processing JWT request header expiration or signature error
        http.oauth2ResourceServer().authenticationEntryPoint(authServerAuthenticationEntryPoint);
        // For the whitelist path, remove the JWT request header directly. If it is not removed, the JWT will be verified in the background
        http.addFilterBefore(whiteListRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
        http.authorizeExchange()
            .pathMatchers(ArrayUtil.toArray(authUrlWhiteListProperties.getUrls(), String.class)).permitAll()
                .anyExchange().access(authorizationManager)
                .and()
                .exceptionHandling()
                .accessDeniedHandler(authServerAccessDeniedHandler) // Processing unauthorized
                .authenticationEntryPoint(authServerAuthenticationEntryPoint) //Handling unauthenticated
                .and()
                .cors()
                .and().csrf().disable();

        return http.build();
    }

    /**
     * ServerHttpSecurity The load part of the authorities in jwt is not regarded as Authentication, and the authorities in jwt's Claim need to be added
     * Solution: redefine the ReactiveAuthenticationManager rights manager and the default converter JwtGrantedAuthoritiesConverter
     */
    @Bean
    public Converter<jwt, ?="" extends="" mono<?="" abstractauthenticationtoken="">> jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstant.AUTHORITY_PREFIX);
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstant.AUTHORITY_CLAIM_NAME);

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
    }
}

11. Create a new AuthorizationManager.java to implement the ReactiveAuthorizationManager interface for custom permission verification

package com.gitegg.gateway.auth;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.util.StringUtils;

import com.gitegg.platform.base.constant.AuthConstant;

import cn.hutool.core.convert.Convert;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;

/**
 * Gateway authentication manager
 */
@Slf4j
@Component
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class AuthorizationManager implements ReactiveAuthorizationManager<authorizationcontext> {

    private final RedisTemplate redisTemplate;

    /**
     * Enable tenant mode
     */
    @Value(("${tenant.enable}"))
    private Boolean enable;

    @Override
    public Mono<authorizationdecision> check(Mono<authentication> mono, AuthorizationContext authorizationContext) {
        ServerHttpRequest request = authorizationContext.getExchange().getRequest();
        String path = request.getURI().getPath();
        PathMatcher pathMatcher = new AntPathMatcher();

        // Direct release of cross domain pre inspection requests
        if (request.getMethod() == HttpMethod.OPTIONS) {
            return Mono.just(new AuthorizationDecision(true));
        }

        // The token is null. Access is denied
        String token = request.getHeaders().getFirst(AuthConstant.JWT_TOKEN_HEADER);
        if (StringUtils.isEmpty(token)) {
            return Mono.just(new AuthorizationDecision(false));
        }

        //  If the tenant mode is enabled, but there is no tenant information in the request header, access is denied
        String tenantId = request.getHeaders().getFirst(AuthConstant.TENANT_ID);
        if (enable && StringUtils.isEmpty(tenantId)) {
            return Mono.just(new AuthorizationDecision(false));
        }

        String redisRoleKey = AuthConstant.TENANT_RESOURCE_ROLES_KEY;
        // Judge whether the tenant mode is enabled. If it is enabled, obtain role permissions by tenant classification
        if (enable) {
            redisRoleKey += tenantId;
        } else {
            redisRoleKey = AuthConstant.RESOURCE_ROLES_KEY;
        }

        //  Cache access resource permission role relationship list
        Map<object, object=""> resourceRolesMap = redisTemplate.opsForHash().entries(redisRoleKey);
        Iterator<object> iterator = resourceRolesMap.keySet().iterator();

        //Statistics of role permission collection authorities required by the resource matched by the request path
        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 -> {
                    // roleId is the role of the requesting user (Format: role {roleId}), and authorities is the collection of roles required by the requesting resource
                    log.info("Access path:{}", path);
                    log.info("User role roleId: {}", roleId);
                    log.info("The resource requires permissions authorities: {}", authorities);
                    return authorities.contains(roleId);
                })
                .map(AuthorizationDecision::new)
                .defaultIfEmpty(new AuthorizationDecision(false));
        return authorizationDecisionMono;
    }
}

12. Create a new AuthGlobalFilter.java global filter, parse the user request information, and put the user information and tenant information in the request Header, so that the follow-up service does not need to parse the JWT token, and can directly obtain the user and tenant information from the request Header.

package com.gitegg.gateway.filter;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.text.ParseException;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;

import com.gitegg.platform.base.constant.AuthConstant;
import com.nimbusds.jose.JWSObject;

import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;

/**
 * A global filter that converts the JWT of the logged in user into user information
 */
@Slf4j
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    /**
     * Enable tenant mode
     */
    @Value(("${tenant.enable}"))
    private Boolean enable;

    @Override
    public Mono<void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        String tenantId = exchange.getRequest().getHeaders().getFirst(AuthConstant.TENANT_ID);

        String token = exchange.getRequest().getHeaders().getFirst(AuthConstant.JWT_TOKEN_HEADER);

        if (StrUtil.isEmpty(tenantId) && StrUtil.isEmpty(token)) {
            return chain.filter(exchange);
        }

        Map<string, string=""> addHeaders = new HashMap<>();

        // If the system configuration has enabled tenant mode, set tenantId
        if (enable && StrUtil.isEmpty(tenantId)) {
            addHeaders.put(AuthConstant.TENANT_ID, tenantId);
        }

        if (!StrUtil.isEmpty(token)) {
        try {
            //Parse the user information from the token and set it to the Header
            String realToken = token.replace("Bearer ", "");
            JWSObject jwsObject = JWSObject.parse(realToken);
            String userStr = jwsObject.getPayload().toString();
            log.info("AuthGlobalFilter.filter() User:{}", userStr);
            addHeaders.put(AuthConstant.HEADER_USER, URLEncoder.encode(userStr, "UTF-8"));

        } catch (ParseException | UnsupportedEncodingException e) {
            e.printStackTrace();
        }
    }

    Consumer<httpheaders> httpHeaders = httpHeader -> {
        addHeaders.forEach((k, v) -> {
            httpHeader.set(k, v);
        });
    };

    ServerHttpRequest request = exchange.getRequest().mutate().headers(httpHeaders).build();
    exchange = exchange.mutate().request(request).build();
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

13. Add permission related configuration information in Nacos:

spring:
  jackson:
    time-zone: Asia/Shanghai
    date-format: yyyy-MM-dd HH:mm:ss
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: 'http://127.0.0.1/gitegg-oauth/oauth/public_key'
# Multi tenant configuration
tenant:
  # Enable tenant mode
  enable: true
  # Multi tenant tables that need to be excluded
  exclusionTable:
    - "t_sys_district"
    - "t_sys_tenant"
    - "t_sys_role"
    - "t_sys_resource"
    - "t_sys_role_resource"
  # Tenant field name
  column: tenant_id
# The gateway releases the white list and configures the path of the white list
white-list:
  urls:
    - "/gitegg-oauth/oauth/public_key"

2, Logging out invalidates the JWT

Because the JWT is stateless and not stored on the server, the JWT cannot be invalidated when the system logs out. We have two ways to refuse to log out of the logged in JWT:

  • JWT white list: save JWT into the cache every time you log in successfully. The cache validity period is consistent with that of JWT. Log out and remove JWT from the cache. Each time the Gateway authenticates and authorizes, it first obtains whether the JWT exists from the cached JWT white list. If it exists, it continues to verify, and if it does not exist, it denies access.

  • JWT blacklist: whenever logging off, save JWT into the cache, parse the expiration time of JWT, and set the cache expiration time to be consistent with JWT. Each time the Gateway authenticates and authorizes, it first obtains whether the JWT exists in the blacklist from the cache. If it exists, access is denied, and if it does not exist, verification continues.

Whether it is a white list or a blacklist, the principles of the implementation methods are basically the same, that is, store the JWT in the cache first, and then judge whether the JWT is effective according to different states. The advantages and disadvantages of the two methods are analyzed below:

  • Blacklist function analysis: the advantage is that the amount of data stored in the cache will be less than that stored in the white list method. The disadvantage is that it is impossible to know how many jwts are currently signed and how many login users are currently online.
  • Whitelist function analysis: the advantage is that when we need to count online users, the whitelist method can approximately obtain the current system login users, and can expand the function of kicking out login users. The disadvantage is that there is a large amount of data storage, and a large number of token s exist in the cache and need to be verified. In case of being attacked, a large amount of information will be leaked.

To sum up, the blacklist method is still used to realize the logout and login function, and the functions of real-time statistics of online people and kicking out users are developed as extended functions. There is no too much business processing logic in the login and logout logic, so as to keep the system low coupling.

In order to ensure the accuracy of JWT effective information to the greatest extent, in addition to clicking the exit login button in the system, it is also necessary to monitor whether to directly close the page and close the browser event to call the system logout interface.

Token and refresh_ The expiration time of the token is inconsistent, which is in the exp field after its resolution. Because we have customized the blacklist mode, when the user clicks to log out, we will refresh_token is also added to the blacklist in refresh_ When the token gets the refresh token, you need to customize the verification refresh_ Whether the token is added to the blacklist.

1. After exiting the login interface, the token and refresh will be returned_ Add token to blacklist

        /**
     * Some thoughts on logging out:
     * 1,If you don't need to log in, you need to pass the token when calling the interface, and the system doesn't verify the validity of the token. At this time, if the system is attacked, it keeps sending a large number of tokens, and finally it will explode redis
     * 2,If the exit interface must be logged in, the system will call token to verify the validity and refresh_ The token is passed through the parameter and added to the blacklist
     * To sum up: select the login method required to call the exit interface
     * @param request
     * @return
     */
    @PostMapping("/logout")
    public Result logout(HttpServletRequest request) {

        String token = request.getHeader(AuthConstant.JWT_TOKEN_HEADER);
        String refreshToken = request.getParameter(AuthConstant.REFRESH_TOKEN);
        long currentTimeSeconds = System.currentTimeMillis() / GitEggConstant.Number.THOUSAND;

        // Set token and refresh_ The token is also added to the blacklist
        String[] tokenArray = new String[GitEggConstant.Number.TWO];
        tokenArray[GitEggConstant.Number.ZERO] = token.replace("Bearer ", "");
        tokenArray[GitEggConstant.Number.ONE] = refreshToken;
        for (int i = GitEggConstant.Number.ZERO; i < tokenArray.length; i++) {
            String realToken = tokenArray[i];
            JSONObject jsonObject = JwtUtils.decodeJwt(realToken);
            String jti = jsonObject.getAsString("jti");
            Long exp = Long.parseLong(jsonObject.getAsString("exp"));
            if (exp - currentTimeSeconds > GitEggConstant.Number.ZERO) {
                redisTemplate.opsForValue().set(AuthConstant.TOKEN_BLACKLIST + jti, jti, (exp - currentTimeSeconds), TimeUnit.SECONDS);
            }
        }
        return Result.success();
    }

2. Gateway adds a token in the authorization manager to determine whether it is added to the blacklist

        //If the token is added to the blacklist, the login exit operation is performed, and access is denied
        String realToken = token.replace("Bearer ", "");
        try {
            JWSObject jwsObject = JWSObject.parse(realToken);
            Payload payload = jwsObject.getPayload();
            JSONObject jsonObject = payload.toJSONObject();
            String jti = jsonObject.getAsString("jti");
            String blackListToken = (String)redisTemplate.opsForValue().get(AuthConstant.TOKEN_BLACKLIST + jti);
            if (!StringUtils.isEmpty(blackListToken)) {
                return Mono.just(new AuthorizationDecision(false));
            }
        } catch (ParseException e) {
            e.printStackTrace();
        }

3. Customize DefaultTokenService and verify refresh_ Is the token added to the blacklist

@Slf4j
public class GitEggTokenServices extends DefaultTokenServices {

    private final RedisTemplate redisTemplate;

    public GitEggTokenServices(RedisTemplate redisTemplate)
    {
        this.redisTemplate = redisTemplate;
    }

    @Transactional(
            noRollbackFor = {InvalidTokenException.class, InvalidGrantException.class}
    )
    @Override
    public OAuth2AccessToken refreshAccessToken(String refreshTokenValue, TokenRequest tokenRequest) throws AuthenticationException {

        JSONObject jsonObject = null;
        String jti = null;
        //If the refreshToken is added to the blacklist, the login exit operation is performed, and access is denied
        try {
            JWSObject jwsObject = JWSObject.parse(refreshTokenValue);
            Payload payload = jwsObject.getPayload();
            jsonObject = payload.toJSONObject();
            jti = jsonObject.getAsString(TokenConstant.JTI);
            String blackListToken = (String)redisTemplate.opsForValue().get(AuthConstant.TOKEN_BLACKLIST + jti);
            if (!StringUtils.isEmpty(blackListToken)) {
                throw new InvalidTokenException("Invalid refresh token (blackList): " + refreshTokenValue);
            }
        } catch (ParseException e) {
            log.error("obtain refreshToken An error occurred while blacklisting:{}", e);
        }

       OAuth2AccessToken oAuth2AccessToken = super.refreshAccessToken(refreshTokenValue, tokenRequest);

        // The RefreshToken does not support reuse. If it is used once, it will be added to the blacklist and will no longer be allowed to be used. After the refresh token is executed, that is, after the RefreshToken is verified, the redis operation can be performed
        if (null != jsonObject && !StringUtils.isEmpty(jti)) {
            long currentTimeSeconds = System.currentTimeMillis() / GitEggConstant.Number.THOUSAND;
            Long exp = Long.parseLong(jsonObject.getAsString(TokenConstant.EXP));
            if (exp - currentTimeSeconds > GitEggConstant.Number.ZERO) {
                redisTemplate.opsForValue().set(AuthConstant.TOKEN_BLACKLIST + jti, jti, (exp - currentTimeSeconds), TimeUnit.SECONDS);
            }
        }

        return oAuth2AccessToken;
    }
}
Test:

1. Get token using password mode
Add TenantId:0 parameter to Headers

2. Through refresh_token refresh token

3. Execute refresh again_ Token refreshes the token because refresh_token has been called once, so it cannot be used again here

3, The front end automatically uses refresh_token refresh token

1. Use the Axios auth refresh public component to refresh the token when the background status returns 401

import axios from 'axios'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
import store from '@/store'
import storage from 'store'
import { serialize } from '@/utils/util'
import notification from 'ant-design-vue/es/notification'
import modal from 'ant-design-vue/es/modal'
import { VueAxios } from './axios'
import { ACCESS_TOKEN, REFRESH_ACCESS_TOKEN } from '@/store/mutation-types'

// Create an axios instance
const request = axios.create({
  // Default prefix for API requests
  baseURL: process.env.VUE_APP_API_BASE_URL,
  timeout: 30000 // Request timeout
})

// When the token fails, the method to refresh the token needs to be called
const refreshAuthLogic = failedRequest =>
  axios.post(process.env.VUE_APP_API_BASE_URL + '/gitegg-oauth/oauth/token',
  serialize({ client_id: process.env.VUE_APP_CLIENT_ID,
      client_secret: process.env.VUE_APP_CLIENT_SECRET,
      grant_type: 'refresh_token',
      refresh_token: storage.get(REFRESH_ACCESS_TOKEN)
    }),
    {
      headers: { 'TenantId': process.env.VUE_APP_TENANT_ID, 'Content-Type': 'application/x-www-form-urlencoded' }
    }
    ).then(tokenRefreshResponse => {
      if (tokenRefreshResponse.status === 200 && tokenRefreshResponse.data && tokenRefreshResponse.data.success) {
        const result = tokenRefreshResponse.data.data
        storage.set(ACCESS_TOKEN, result.tokenHead + result.token, result.expiresIn * 1000)
        storage.set(REFRESH_ACCESS_TOKEN, result.refreshToken, result.refreshExpiresIn * 1000)
        failedRequest.response.config.headers['Authorization'] = result.tokenHead + result.token
      }
      return Promise.resolve()
})

// Initialize refresh token interceptor
createAuthRefreshInterceptor(request, refreshAuthLogic, {
  pauseInstanceWhileRefreshing: true // When the refresh token is executed, other requests are suspended
})

// Exception interceptor
const errorHandler = (error) => {
  if (error.response) {
    const data = error.response.data
    if (error.response.status === 403) {
      notification.error({
        message: 'No access',
        description: data.message
      })
    } else if (error.response.status === 401 && !(data.result && data.result.isLogin)) {
      // When the refresh token times out, it will be transferred to the login page
      modal.warn({
        title: 'login timeout ',
        content: 'Since you haven't operated for a long time, please log in to the system again for subsequent operations to ensure safety!',
        okText: 'Login again',
        onOk () {
            store.dispatch('Timeout').then(() => {
                window.location.reload()
            })
         }
      })
    }
  }
  return Promise.reject(error)
}

// request interceptor
request.interceptors.request.use(config => {
  const token = storage.get(ACCESS_TOKEN)
  // If the token exists
  // Let each request carry a custom token. Please modify it according to the actual situation
  if (token) {
    config.headers['Authorization'] = token
  }
  config.headers['TenantId'] = process.env.VUE_APP_TENANT_ID
  return config
}, errorHandler)

// response interceptor
request.interceptors.response.use((response) => {
  const res = response.data
  if (res.code) {
    if (res.code !== 200) {
      notification.error({
        message: 'operation failed',
        description: res.msg
      })
      return Promise.reject(new Error(res.msg || 'Error'))
    } else {
      return response.data
    }
  } else {
    return response
  }
}, errorHandler)

const installer = {
  vm: {},
  install (Vue) {
    Vue.use(VueAxios, request)
  }
}

export default request

export {
  installer as VueAxios,
  request as axios
}

4, Remember password function implementation

Sometimes, we can realize the function of remembering passwords on trusted computers. The implementation of front and back-end separation projects only needs to record the passwords in localstorage, and then fill them in automatically every time we visit the login interface. Here, plaintext is used for storage. For system security, the password needs to be encrypted and stored in the actual application process, and the encrypted password is verified in the background
1. Read whether to remember the password in created

created () {
    this.queryCaptchaType()
      this.$nextTick(() => {
        const rememberMe = storage.get(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-rememberMe')
          if (rememberMe) {
            const username = storage.get(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-username')
            const password = storage.get(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-password')
            if (username !== '' && password !== '') {
            this.form.setFieldsValue({ 'username': username })
            this.form.setFieldsValue({ 'password': password })
            this.form.setFieldsValue({ 'rememberMe': true })
          }
        }
      })
  },

2. After each successful login, determine whether to fill in the user name and password according to whether to check remember password

     // Determine whether to remember the password
      const rememberMe = this.form.getFieldValue('rememberMe')
      const username = this.form.getFieldValue('username')
      const password = this.form.getFieldValue('password')
      if (rememberMe && username !== '' && password !== '') {
          storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-username', username, 60 * 60 * 24 * 7 * 1000)
          storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-password', password, 60 * 60 * 24 * 7 * 1000)
          storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-rememberMe', true, 60 * 60 * 24 * 7 * 1000)
      } else {
         storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-username')
         storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-password')
         storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-rememberMe')
      }

5, If the password attempts are too many, the account will be locked

In terms of system security, we need to support measures to prevent user accounts from being brutally cracked. At present, the technology can easily crack most authentication codes, which provides convenience for brutally cracking user accounts. Here, our system needs the function of locking accounts with too many password attempts. The UserDetails interface of spring security defines the isAccountNonLocked method to determine whether the account is locked

public interface UserDetails extends Serializable {
    Collection<!--? extends GrantedAuthority--> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

1. Customize the LoginFailureListener event listener, listen for the AuthenticationFailureBadCredentialsEvent exception event thrown by spring security, and use the Redis counter to record the number of account error passwords

/**
 * Call when login fails, and lock the account when there are too many password errors
 * @author GitEgg
 * @date 2021-03-12 17:57:05
 **/
@Slf4j
@Component
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class LoginFailureListener implements ApplicationListener<authenticationfailurebadcredentialsevent> {

    private final UserDetailsService userDetailsService;

    private final RedisTemplate redisTemplate;

    @Value("${system.maxTryTimes}")
    private int maxTryTimes;

    @Override
    public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) {

        if (event.getException().getClass().equals(UsernameNotFoundException.class)) {
            return;
        }

        String userName = event.getAuthentication().getName();

        GitEggUserDetails user = (GitEggUserDetails) userDetailsService.loadUserByUsername(userName);

        if (null != user) {
            Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + user.getId()).get();
            if(null == lockTimes || (int)lockTimes <= maxTryTimes){
                redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + user.getId()).increment(GitEggConstant.Number.ONE);
            }
        }
    }
}

2. The GitEggUserDetailsServiceImpl method queries the number of account locks recorded by Redis

            // Judge whether the account is locked (account expiration, voucher expiration, etc. can be extended here)
            Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).get();
            boolean accountNotLocked = true;
            if(null != lockTimes && (int)lockTimes >= maxTryTimes){
                accountNotLocked = false;
            }

6, Do you need to enter a verification code when logging in

During the first three (configurable) logins of verification code setting, it is not necessary to enter the verification code. When the number of password attempts is greater than three, it is necessary to enter the verification code. One idea of login method: when entering the login interface initially, users can choose their own login method. Our system OAuth sets three login methods by default:

  • User name + password login
  • User name + password + verification code
  • Mobile number + verification code login

By default, the system uses user name + password to log in. When the number of login errors of the default user name and password (once by default) exceeds the maximum number configured by the system, you must enter the verification code to log in. When the verification code also exceeds a certain number of times (five by default), if it fails, you can continue to try after locking the account for two hours. Considering that some systems may not use SMS verification code, it is an extended function here: if necessary, you can force only SMS verification code to log in when there are too many user name and password errors, and you must set it to lock after the number of errors.
1. Add account judgment in the custom GitEggUserDetailsServiceImpl

            // Number of incorrect account and password obtained from Redis
            Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).get();

            // Judge whether the account and password are entered incorrectly several times. If they are entered incorrectly many times, the account will be locked
            // If the input error is greater than the configured number of times, you must select captcha or sms_captcha
            if (null != lockTimes && (int)lockTimes >= maxNonCaptchaTimes && ( StringUtils.isEmpty(authGrantType) || (!StringUtils.isEmpty(authGrantType)
                    && !AuthEnum.SMS_CAPTCHA.code.equals(authGrantType) && !AuthEnum.CAPTCHA.code.equals(authGrantType)))) {
                throw new GitEggOAuth2Exception(ResultCodeEnum.INVALID_PASSWORD_CAPTCHA.msg);
            }

            // Judge whether the account is locked (account expiration, voucher expiration, etc. can be extended here)
            if(null != lockTimes && (int)lockTimes >= maxTryTimes){
                throw new LockedException(ResultCodeEnum.PASSWORD_TRY_MAX_ERROR.msg);
            }

            // Judge whether the account is disabled
            String userStatus = gitEggUser.getStatus();
            if (String.valueOf(GitEggConstant.DISABLE).equals(userStatus)) {
                throw new DisabledException(ResultCodeEnum.DISABLED_ACCOUNT.msg);
            }

2. Customize OAuth2 to intercept exceptions and handle them uniformly

/**
 * Custom Oauth exception interceptor
 */
@Slf4j
@RestControllerAdvice
public class GitEggOAuth2ExceptionHandler {

    @ExceptionHandler(InvalidTokenException.class)
    public Result handleInvalidTokenException(InvalidTokenException e) {
        return Result.error(ResultCodeEnum.UNAUTHORIZED);
    }

    @ExceptionHandler({UsernameNotFoundException.class})
    public Result handleUsernameNotFoundException(UsernameNotFoundException e) {
        return Result.error(ResultCodeEnum.INVALID_USERNAME_PASSWORD);
    }

    @ExceptionHandler({InvalidGrantException.class})
    public Result handleInvalidGrantException(InvalidGrantException e) {
        return Result.error(ResultCodeEnum.INVALID_USERNAME_PASSWORD);
    }

    @ExceptionHandler(InternalAuthenticationServiceException.class)
    public Result handleInvalidGrantException(InternalAuthenticationServiceException e) {
        Result result = Result.error(ResultCodeEnum.INVALID_USERNAME_PASSWORD);
        if (null != e) {
            String errorMsg = e.getMessage();
            if (ResultCodeEnum.INVALID_PASSWORD_CAPTCHA.getMsg().equals(errorMsg)) {
                //Verification code must be used
                result = Result.error(ResultCodeEnum.INVALID_PASSWORD_CAPTCHA);
            }
            else if (ResultCodeEnum.PASSWORD_TRY_MAX_ERROR.getMsg().equals(errorMsg)) {
                //Account locked
                result = Result.error(ResultCodeEnum.PASSWORD_TRY_MAX_ERROR);
            }
            else if (ResultCodeEnum.DISABLED_ACCOUNT.getMsg().equals(errorMsg)) {
                //Account disabled
                result = Result.error(ResultCodeEnum.DISABLED_ACCOUNT);
            }
        }
        return result;
    }
}

3. Judgment is added on the front-end login page. The default login mode is password. When the error reaches a certain number of times, you must log in with the verification code

    requestFailed (err) {
      this.isLoginError = true
      if (err && err.code === 427) {
        // The number of password errors exceeds the maximum limit, please select the verification code mode to log in
        if (this.customActiveKey === 'tab_account') {
            this.grantType = 'captcha'
        } else {
            this.grantType = 'sms_captcha'
        }
        this.loginErrorMsg = err.msg
        if (this.loginCaptchaType === 'sliding') {
            this.$refs.verify.show()
        }
      } else if (err) {
            this.loginErrorMsg = err.msg
      }
    }

remarks:
1, When verifying report 401:
http basic authentication is not performed during the post request of / auth/token.
What is http Basic authentication?
An authentication method of http protocol. The client ID and client password are spliced in the format of "client ID: client password", encoded with base64 and placed in
Request server in header. Examples are as follows:
Authorization: Basic ASDLKFALDSFAJSLDFKLASD=
ASDLKFALDSFAJSLDFKLASD = client ID: 64 code of client password
2, JWT never expires:
When customizing the TokenEnhancer, milliseconds are added to the expiration time. During authentication resolution, OAuth2 is resolved in seconds, so the generated expiration time is very large, resulting in that the token has not expired.

Source address:

Gitee: https://gitee.com/wmz1930/GitEgg
GitHub: https://github.com/wmz1930/GitEgg

Tags: Spring Cloud Microservices

Posted on Fri, 26 Nov 2021 06:48:59 -0500 by copernic67