SpringCloud微服務實戰——搭建企業級開發框架(二十三):Gateway+OAuth2+JWT實現微服務統一認證授權

全棧程式猿發表於2021-11-26

  OAuth2是一個關於授權的開放標準,核心思路是通過各類認證手段(具體什麼手段OAuth2不關心)認證使用者身份,並頒發token(令牌),使得第三方應用可以使用該token(令牌)在限定時間、限定範圍訪問指定資源。
  OAuth2中使用token驗證使用者登入合法性,但token最大的問題是不攜帶使用者資訊,資源伺服器無法在本地進行驗證,每次對於資源的訪問,資源伺服器都需要向認證伺服器發起請求,一是驗證token的有效性,二是獲取token對應的使用者資訊。如果有大量的此類請求,無疑處理效率是很低,且認證伺服器會變成一箇中心節點,這在分散式架構下很影響效能。如果認證伺服器頒發的是jwt格式的token,那麼資源伺服器就可以直接自己驗證token的有效性並繫結使用者,這無疑大大提升了處理效率且減少了單點隱患。
  SpringCloud認證授權解決思路:認證服務負責認證,閘道器負責校驗認證和鑑權,其他API服務負責處理自己的業務邏輯。安全相關的邏輯只存在於認證服務和閘道器服務中,其他服務只是單純地提供服務而沒有任何安全相關邏輯。
微服務鑑權功能劃分:

  • gitegg-oauth:Oauth2使用者認證和單點登入
  • gitegg-gateway:請求轉發和統一鑑權
  • gitegg-system: 讀取系統配置的RBAC許可權配置並存放到快取

一、鑑權配置

1、GitEgg-Platform工程下新建gitegg-platform-oauth2工程,用於統一管理OAuth2版本,及統一配置

<!--?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、在gitegg-oauth工程中引入需要的庫

<!--?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資料庫驅動及連線池 -->
        <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>
        <!-- 驗證碼 -->
        <dependency>
            <groupid>com.gitegg.platform</groupid>
            <artifactid>gitegg-platform-captcha</artifactid>
        </dependency>
        <!-- gitegg-service-system 的fegin公共呼叫方法 -->
        <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可以使用HMAC演算法或使用RSA的公鑰/私鑰對來簽名,防止被篡改。首先我們使用keytool生成RSA證照gitegg.jks,複製到gitegg-oauth工程的resource目錄下,CMD命令列進入到JDK安裝目錄的bin目錄下, 使用keytool命令生成gitegg.jks證照

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

4、新建GitEggUserDetailsServiceImpl.java實現SpringSecurity獲取使用者資訊介面,用於SpringSecurity鑑權時獲取使用者資訊

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;

/**
 *  實現SpringSecurity獲取使用者資訊介面
 *
 * @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) {

        // 獲取登入型別,密碼,二維碼,驗證碼
        String authLoginType = request.getParameter(AuthConstant.AUTH_TYPE);

        // 獲取客戶端id
        String clientId = request.getParameter(AuthConstant.AUTH_CLIENT_ID);

        // 遠端呼叫返回資料
        Result<object> result;

        // 通過手機號碼登入
        if (!StringUtils.isEmpty(authLoginType) && AuthEnum.PHONE.code.equals(authLoginType))
        {
            String phone = request.getParameter(AuthConstant.PHONE_NUMBER);
            result = userFeign.queryUserByPhone(phone);
        }
        // 通過賬號密碼登入
        else if(!StringUtils.isEmpty(authLoginType) && AuthEnum.QR.code.equals(authLoginType))
        {
            result = userFeign.queryUserByAccount(username);
        }
        else
        {
            result = userFeign.queryUserByAccount(username);
        }

        // 判斷返回資訊
        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、新建AuthorizationServerConfig.java用於認證服務相關配置,正式環境請一定記得修改gitegg.jks配置的密碼,這裡預設為123456。TokenEnhancer 為登入使用者的擴充套件資訊,可以自己定義。

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;

/**
 * 認證服務配置
 */
@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;

    /**
     * 客戶端資訊配置
     */
    @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);
    }

    /**
     * 配置授權(authorization)以及令牌(token)的訪問端點和令牌服務(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);

        // 獲取自定義tokenGranter
        TokenGranter tokenGranter = GitEggTokenGranter.getTokenGranter(authenticationManager, endpoints, redisTemplate,
            userFeign, captchaService, captchaType);

        endpoints.authenticationManager(authenticationManager)
                .accessTokenConverter(jwtAccessTokenConverter())
                .tokenEnhancer(tokenEnhancerChain)
                .userDetailsService(userDetailsService)
            .tokenGranter(tokenGranter)
                /**
                 *
                 * refresh_token有兩種使用方式:重複使用(true)、非重複使用(false),預設為true
                 * 1.重複使用:access_token過期重新整理時, refresh token過期時間未改變,仍以初次生成的時間為準
                 * 2.非重複使用:access_token過期重新整理時, refresh_token過期時間延續,在refresh_token有效期內重新整理而無需失效再次登入
                 */
                .reuseRefreshTokens(false);
    }

    /**
     * 允許表單認證
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) {
        security.allowFormAuthenticationForClients()
                .tokenKeyAccess("permitAll()")
                .checkTokenAccess("isAuthenticated()");
    }

    /**
     * 使用非對稱加密演算法對token簽名
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(keyPair());
        return converter;
    }

    /**
     * 從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內容增強
     */
    @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());
            //不把許可權選單放到jwt裡面,當選單太多時,會導致jwt長度不可控
//            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、Gateway在認證授權時需要RSA的公鑰來驗證簽名是否合法,所以這裡新建GitEggOAuthController的getKey介面用於Gateway獲取RSA公鑰

    @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、新建ResourceServerConfig.java資源伺服器配置,放開public_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、在gitegg-service-system新建InitResourceRolesCacheRunner.java實現CommandLineRunner介面,用於系統啟動時載入RBAC許可權配置資訊到快取

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;

/**
 * 容器啟動完成載入資源許可權資料到快取
 */
@Slf4j
@RequiredArgsConstructor(onConstructor_ = @Autowired)
@Component
public class InitResourceRolesCacheRunner implements CommandLineRunner {

    private final RedisTemplate redisTemplate;

    private final IResourceService resourceService;

    /**
     * 是否開啟租戶模式
     */
    @Value(("${tenant.enable}"))
    private Boolean enable;

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

        log.info("InitResourceRolesCacheRunner running");

        // 查詢系統角色和許可權的關係
        List<resource> resourceList = resourceService.queryResourceRoleIds();

        // 判斷是否開啟了租戶模式,如果開啟了,那麼角色許可權需要按租戶進行分類儲存
        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、新建閘道器服務gitegg-gateway,作為Oauth2的資源服務、客戶端服務使用,對訪問微服務的請求進行轉發、統一校驗認證和鑑權操作,引入相關依賴

<!--?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 服務註冊發現 -->
        <dependency>
            <groupid>com.alibaba.cloud</groupid>
            <artifactid>spring-cloud-starter-alibaba-nacos-discovery</artifactid>
        </dependency>
        <!-- Nacos 分散式配置 -->
        <dependency>
            <groupid>com.alibaba.cloud</groupid>
            <artifactid>spring-cloud-starter-alibaba-nacos-config</artifactid>
        </dependency>
        <!-- OpenFeign 微服務呼叫解決方案 -->
        <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自定義擴充套件 -->
        <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、新建AuthResourceServerConfig.java對gateway閘道器服務進行配置安全配置,需要使用@EnableWebFluxSecurity而非@EnableWebSecurity,因為SpringCloud Gateway基於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;

/**
 * 資源伺服器配置
 */
@AllArgsConstructor
@Configuration
// 註解需要使用@EnableWebFluxSecurity而非@EnableWebSecurity,因為SpringCloud Gateway基於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());
        // 自定義處理JWT請求頭過期或簽名錯誤的結果
        http.oauth2ResourceServer().authenticationEntryPoint(authServerAuthenticationEntryPoint);
        // 對白名單路徑,直接移除JWT請求頭,不移除的話,後臺會校驗jwt
        http.addFilterBefore(whiteListRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
        http.authorizeExchange()
            .pathMatchers(ArrayUtil.toArray(authUrlWhiteListProperties.getUrls(), String.class)).permitAll()
                .anyExchange().access(authorizationManager)
                .and()
                .exceptionHandling()
                .accessDeniedHandler(authServerAccessDeniedHandler) // 處理未授權
                .authenticationEntryPoint(authServerAuthenticationEntryPoint) //處理未認證
                .and()
                .cors()
                .and().csrf().disable();

        return http.build();
    }

    /**
     * ServerHttpSecurity沒有將jwt中authorities的負載部分當做Authentication,需要把jwt的Claim中的authorities加入
     * 解決方案:重新定義ReactiveAuthenticationManager許可權管理器,預設轉換器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、新建AuthorizationManager.java實現ReactiveAuthorizationManager介面,用於自定義許可權校驗

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;

/**
 * 閘道器鑑權管理器
 */
@Slf4j
@Component
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class AuthorizationManager implements ReactiveAuthorizationManager<authorizationcontext> {

    private final RedisTemplate redisTemplate;

    /**
     * 是否開啟租戶模式
     */
    @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();

        // 對應跨域的預檢請求直接放行
        if (request.getMethod() == HttpMethod.OPTIONS) {
            return Mono.just(new AuthorizationDecision(true));
        }

        // token為空拒絕訪問
        String token = request.getHeaders().getFirst(AuthConstant.JWT_TOKEN_HEADER);
        if (StringUtils.isEmpty(token)) {
            return Mono.just(new AuthorizationDecision(false));
        }

        //  如果開啟了租戶模式,但是請求頭裡沒有租戶資訊,那麼拒絕訪問
        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;
        // 判斷是否開啟了租戶模式,如果開啟了,那麼按租戶分類的方式獲取角色許可權
        if (enable) {
            redisRoleKey += tenantId;
        } else {
            redisRoleKey = AuthConstant.RESOURCE_ROLES_KEY;
        }

        //  快取取資源許可權角色關係列表
        Map<object, object=""> resourceRolesMap = redisTemplate.opsForHash().entries(redisRoleKey);
        Iterator<object> iterator = resourceRolesMap.keySet().iterator();

        //請求路徑匹配到的資源需要的角色許可權集合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 -> {
                    // roleId是請求使用者的角色(格式:ROLE_{roleId}),authorities是請求資源所需要角色的集合
                    log.info("訪問路徑:{}", path);
                    log.info("使用者角色roleId:{}", roleId);
                    log.info("資源需要許可權authorities:{}", authorities);
                    return authorities.contains(roleId);
                })
                .map(AuthorizationDecision::new)
                .defaultIfEmpty(new AuthorizationDecision(false));
        return authorizationDecisionMono;
    }
}

12、新建AuthGlobalFilter.java全域性過濾器,解析使用者請求資訊,將使用者資訊及租戶資訊放在請求的Header中,這樣後續服務就不需要解析JWT令牌了,可以直接從請求的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;

/**
 * 將登入使用者的JWT轉化成使用者資訊的全域性過濾器
 */
@Slf4j
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    /**
     * 是否開啟租戶模式
     */
    @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<>();

        // 如果系統配置已開啟租戶模式,設定tenantId
        if (enable && StrUtil.isEmpty(tenantId)) {
            addHeaders.put(AuthConstant.TENANT_ID, tenantId);
        }

        if (!StrUtil.isEmpty(token)) {
        try {
            //從token中解析使用者資訊並設定到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、在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'
# 多租戶配置
tenant:
  # 是否開啟租戶模式
  enable: true
  # 需要排除的多租戶的表
  exclusionTable:
    - "t_sys_district"
    - "t_sys_tenant"
    - "t_sys_role"
    - "t_sys_resource"
    - "t_sys_role_resource"
  # 租戶欄位名稱
  column: tenant_id
# 閘道器放行白名單,配置白名單路徑
white-list:
  urls:
    - "/gitegg-oauth/oauth/public_key"

二、登出登入使JWT失效

因為JWT是無狀態的且不在服務端儲存,所以,當系統在執行退出登入時就無法使JWT失效,我們有兩種方式拒絕登出登入後的JWT:

  • JWT白名單:每次登入成功就將JWT存到快取中,快取有效期和JWT有效期保持一致,登出登入就將JWT從快取中移出。Gateway每次認證授權先從快取JWT白名單中獲取是否存在該JWT,存在則繼續校驗,不存在則拒絕訪問。

  • JWT黑名單:每當登出登入時,將JWT存到快取中,解析JWT的到期時間,將快取過期時間設定為和JWT一致。Gateway每次認證授權先從快取中獲取JWT是否存在於黑名單中,存在則拒絕訪問,不存在則繼續校驗。

不管是白名單還是黑名單,實現方式的原理都基本一致,就是將JWT先存放到快取,再根據不同的狀態進行判斷JWT是否有效,下面是兩種方式的優缺點分析:

  • 黑名單功能分析:優點是存放到快取的資料量將小於白名單方式存放的資料量,缺點是無法獲知當前簽發了多少JWT,當前線上多少登入使用者。
  • 白名單功能分析:優點是當我們需要統計線上使用者的時候,白名單方式可以近似的獲取到當前系統登入使用者,可以擴充套件踢出登入使用者的功能。缺點是資料儲存量大,且大量token存在快取中需要進行校驗,萬一被攻擊會導致大量資訊洩露。

綜上考慮,還是採用黑名單的方式來實現登出登入功能,實時統計線上人數和踢出使用者等功能作為擴充套件功能來開發,不在登入登出邏輯中摻雜太多的業務處理邏輯,使系統保持低耦合。

為了使JWT有效資訊最大程度保證準確性,登出登入除了在系統點選退出登入按鈕,還需要監測是否直接關閉頁面,關閉瀏覽器事件,來執行呼叫系統登出介面

token和refresh_token的過期時間不一致,都在其解析之後的exp欄位。因為我們定製了黑名單模式,當使用者點選退出登入之後,我們會把refresh_token也加入黑名單,在refresh_token獲取重新整理token的時候,需要定製校驗refresh_token是否被加入到黑名單。

1、退出登入介面將token和refresh_token加入黑名單

        /**
     * 退出登入需要需要登入的一點思考:
     * 1、如果不需要登入,那麼在呼叫介面的時候就需要把token傳過來,且系統不校驗token有效性,此時如果系統被攻擊,不停的大量傳送token,最後會把redis充爆
     * 2、如果呼叫退出介面必須登入,那麼系統會呼叫token校驗有效性,refresh_token通過引數傳過來加入黑名單
     * 綜上:選擇呼叫退出介面需要登入的方式
     * @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;

        // 將token和refresh_token同時加入黑名單
        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在AuthorizationManager中新增token是否加入黑名單的判斷

        //如果token被加入到黑名單,就是執行了退出登入操作,那麼拒絕訪問
        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、自定義DefaultTokenService,校驗refresh_token是否被加入黑名單

@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;
        //如果refreshToken被加入到黑名單,就是執行了退出登入操作,那麼拒絕訪問
        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("獲取refreshToken黑名單時發生錯誤:{}", e);
        }

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

        // RefreshToken不支援重複使用,如果使用一次,則加入黑名單不再允許使用,當重新整理token執行完之後,即校驗過RefreshToken之後,才執行存redis操作
        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;
    }
}
測試:

1、使用密碼模式獲取token
Headers裡面加TenantId:0引數
密碼模式獲取token
2、通過refresh_token重新整理token
refresh_token重新整理token
3、再次執行refresh_token重新整理token,此時因為refresh_token已經呼叫過一次,所以這裡不能再次使用
refresh_token已過期

三、前端自動使用refresh_token重新整理token

1、使用axios-auth-refresh公共元件,當後臺狀態返回401時,進行token重新整理操作

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'

// 建立 axios 例項
const request = axios.create({
  // API 請求的預設字首
  baseURL: process.env.VUE_APP_API_BASE_URL,
  timeout: 30000 // 請求超時時間
})

// 當token失效時,需要呼叫的重新整理token的方法
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()
})

// 初始化重新整理token攔截器
createAuthRefreshInterceptor(request, refreshAuthLogic, {
  pauseInstanceWhileRefreshing: true // 當重新整理token執行時,暫停其他請求
})

// 異常攔截處理器
const errorHandler = (error) => {
  if (error.response) {
    const data = error.response.data
    if (error.response.status === 403) {
      notification.error({
        message: '禁止訪問',
        description: data.message
      })
    } else if (error.response.status === 401 && !(data.result && data.result.isLogin)) {
      // 當重新整理token超時,則調到登入頁面
      modal.warn({
        title: '登入超時',
        content: '由於您長時間未操作, 為確保安全, 請重新登入系統進行後續操作 !',
        okText: '重新登入',
        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)
  // 如果 token 存在
  // 讓每個請求攜帶自定義 token 請根據實際情況自行修改
  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: '操作失敗',
        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
}

四、記住密碼功能實現

有時候,在我們在可信任的電腦上可以實現記住密碼功能,前後端分離專案的實現只需要把密碼記錄到localstorage中,然後每次訪問登入介面時,自動填入即可。這裡先使用明文進行儲存,為了系統安全,在實際應用過程需要將密碼加密儲存,後臺校驗加密後的密碼
1、在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、每次登入成功之後,根據是否勾選記住密碼來確定是否填入使用者名稱密碼

     // 判斷是否記住密碼
      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')
      }

五、密碼嘗試次數過多則鎖定賬戶

從系統安全方面來講,我們需要支援防止使用者賬戶被暴力破解的措施,目前技術已經能夠輕鬆破解大多數的驗證碼,這為暴力破解使用者賬戶提供了方便,那麼這裡我們的系統需要密碼嘗試次數過多鎖定賬戶的功能。SpringSecurity的UserDetails介面定義了isAccountNonLocked方法來判斷賬戶是否被鎖定

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

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

1、自定義LoginFailureListener事件監聽器,監聽SpringSecurity丟擲AuthenticationFailureBadCredentialsEvent異常事件,使用Redis計數器,記錄賬號錯誤密碼次數

/**
 * 當登入失敗時的呼叫,當密碼錯誤過多時,則鎖定賬戶
 * @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、GitEggUserDetailsServiceImpl方法查詢Redis記錄的賬號鎖定次數

            // 判斷賬號是否被鎖定(賬戶過期,憑證過期等可在此處擴充套件)
            Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).get();
            boolean accountNotLocked = true;
            if(null != lockTimes && (int)lockTimes >= maxTryTimes){
                accountNotLocked = false;
            }

六、登入時是否需要輸入驗證碼

驗證碼設定前三次(可配置)登入時,不需要輸入驗證碼,當密碼嘗試次數大於三次時,需要輸入驗證碼,登入方式的一個思路:初始進入登入介面,使用者可選擇自己的登入方式,我們系統OAuth預設設定了三種登入方式:

  • 使用者名稱+密碼登入
  • 使用者名稱+密碼+驗證碼
  • 手機號+驗證碼登入

系統預設採用使用者名稱+密碼登入,當預設的使用者名稱密碼登入錯誤次數(預設一次)超過系統配置的最大次數時,則必須輸入驗證碼登入,當驗證碼也超過一定次數時(預設五次),都不行則鎖定賬戶二小時之後才可以繼續嘗試。因為考慮到有些系統可能不會用到簡訊驗證碼等,所以這裡作為一個擴充套件功能:如果有需要可以在使用者名稱密碼錯誤過多時,強制只用簡訊驗證碼才能登入,且一定要設定超過錯誤次數就鎖定。
1、在自定義的GitEggUserDetailsServiceImpl增加賬號判斷

            // 從Redis獲取賬號密碼錯誤次數
            Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).get();

            // 判斷賬號密碼輸入錯誤幾次,如果輸入錯誤多次,則鎖定賬號
            // 輸入錯誤大於配置的次數,必須選擇captcha或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);
            }

            // 判斷賬號是否被鎖定(賬戶過期,憑證過期等可在此處擴充套件)
            if(null != lockTimes && (int)lockTimes >= maxTryTimes){
                throw new LockedException(ResultCodeEnum.PASSWORD_TRY_MAX_ERROR.msg);
            }

            // 判斷賬號是否被禁用
            String userStatus = gitEggUser.getStatus();
            if (String.valueOf(GitEggConstant.DISABLE).equals(userStatus)) {
                throw new DisabledException(ResultCodeEnum.DISABLED_ACCOUNT.msg);
            }

2、自定義OAuth2攔截異常並統一處理

/**
 * 自定義Oauth異常攔截處理器
 */
@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)) {
                //必須使用驗證碼
                result = Result.error(ResultCodeEnum.INVALID_PASSWORD_CAPTCHA);
            }
            else if (ResultCodeEnum.PASSWORD_TRY_MAX_ERROR.getMsg().equals(errorMsg)) {
                //賬號被鎖定
                result = Result.error(ResultCodeEnum.PASSWORD_TRY_MAX_ERROR);
            }
            else if (ResultCodeEnum.DISABLED_ACCOUNT.getMsg().equals(errorMsg)) {
                //賬號被禁用
                result = Result.error(ResultCodeEnum.DISABLED_ACCOUNT);
            }
        }
        return result;
    }
}

3、前端登入頁面增加判斷,預設採用password方式登入,當錯誤達到一定次數時,必須使用驗證碼登入

    requestFailed (err) {
      this.isLoginError = true
      if (err && err.code === 427) {
        // 密碼錯誤次數超過最大限值,請選擇驗證碼模式登入
        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
      }
    }

備註:
一、當驗證報401時:
進行 /auth/token 的post請求時,沒有進行http basic認證。
什麼是http Basic認證?
http協議的一種認證方式,將客戶端id和客戶端密碼按照“客戶端ID:客戶端密碼”的格式拼接,並用base64編碼,放在
header中請求服務端。例子如下:
Authorization:Basic ASDLKFALDSFAJSLDFKLASD=
ASDLKFALDSFAJSLDFKLASD= 就是 客戶端ID:客戶端密碼 的64編碼
二、JWT一直不過期:
在自定義TokenEnhancer時,將毫秒加入到了過期時間中,在鑑權解析時,OAuth2是按照秒來解析,所以生成的過期時間非常大,導致token一直未過期。

原始碼地址:

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

相關文章