【Spring Cloud & Alibaba 實戰 | 總結篇】Spring Cloud Gateway + Spring Security OAuth2 + JWT 實現微服務統一認證授權和鑒權,基於RBAC設計的適配微服務開發模式權限框架


一. 前言

hi,大家好~ 好久沒更文了,期間主要致力於項目的功能升級和問題修復中,經過一年時間這里只貼出關鍵部分代碼的打磨,【有來】終於迎來v2.0版本,相較於v1.x版本主要完善了OAuth2認證授權、鑒權的邏輯,結合小伙伴提出來的建議,。

寫這篇文章的除了對一年來項目的階段性總結,也是希望幫助大家快速理解當下流行的OAuth2認證授權模式,2.0以及其在當下主流的微服務+前后端分離開發模式(Spring Cloud + Vue)的實踐應用。

在此之前自己有寫過有關 Spring Security OAuth2 + Gateway 統一認證授權+鑒權 和 基於網關統一鑒權的RBAC權限設計的兩篇文章:

Spring Cloud實戰 | 第六篇:Spring Cloud + Spring Security OAuth2 + JWT實現微服務統一認證鑒權

Spring Cloud實戰 | 第十一篇:Spring Cloud Gateway統一鑒權下針對RESTful接口的RBAC權限設計方案,附Vue按鈕權限控制

本篇可以說是在項目升級后對上面兩篇文章的總結。

二. 項目介紹

1. 項目簡介

youlai-mall 是基於Spring Boot 2.5.0、Spring Cloud 2020 、Spring Cloud Alibaba 2021、vue、element-ui、uni-app快速構建的一套全棧開源商城平台,包括后端微服務、前端管理、微信小程序和APP應用。

2. 項目源碼

項目名稱 碼雲(Gitee) Github
微服務后台 youlai-mall youlai-mall
系統管理前端 youlai-mall-admin youlai-mall-admin
微信小程序 youlai-mall-weapp youlai-mall-weapp
APP端【暫不更新】 youlai-mall-app youlai-mall-app
碼雲(Gitee) GitHub
image-20210627170349146

3. 項目預覽

線上預覽地址

地址: www.youlai.tech 用戶名/密碼:admin/123456

系統管理端

image-20210621004954228 image-20210621005011310
image-20210621005123432

微信小程序

4. 項目文檔

  • Spring Cloud 實戰
  1. Spring Cloud實戰 | 第一篇:Windows搭建Nacos服務
  2. Spring Cloud實戰 | 第二篇:Spring Cloud整合Nacos實現注冊中心
  3. Spring Cloud實戰 | 第三篇:Spring Cloud整合Nacos實現配置中心
  4. Spring Cloud實戰 | 第四篇:Spring Cloud整合Gateway實現API網關
  5. Spring Cloud實戰 | 第五篇:Spring Cloud整合OpenFeign實現微服務之間的調用
  6. Spring Cloud實戰 | 第六篇:Spring Cloud Gateway+Spring Security OAuth2+JWT實現微服務統一認證授權
  7. Spring Cloud實戰 | 最七篇:Spring Cloud Gateway+Spring Security OAuth2認證授權模式注銷JWT失效方案
  8. Spring Cloud實戰 | 最八篇:Spring Cloud + Spring Security OAuth2+ Vue前后端分離模式下無感知刷新實現JWT續期
  9. Spring Cloud實戰 | 最九篇:Spring Cloud + Spring Security OAuth2認證服務器統一認證自定義異常處理
  10. Spring Cloud實戰 | 第十篇 : Spring Cloud + Nacos整合Seata 1.4.1實現分布式事務
  11. Spring Cloud實戰 | 第十一篇:Spring Cloud Gateway統一鑒權下針對RESTful接口的RBAC權限設計方案,附Vue按鈕權限控制
  12. Spring Cloud & Alibaba 實戰 | 第十二篇: Sentinel+Nacos實現網關和普通流控、熔斷降級
  • vue + element-ui實戰
  1. vue-element-admin實戰 | 第一篇: 移除mock接入微服務接口,搭建Spring Cloud+Vue前后端分離管理平台
  2. vue-element-admin實戰 | 第二篇: 最小改動接入后台接口實現根據權限動態加載菜單
  • uni-app 實戰
  1. uni-app實戰 | 第一篇:從0到1快速開發一個商城微信小程序,無縫接入Spring Security OAuth2認證授權登錄

5. 版本升級

此次升級2.0版本主要內容和說明整理如下:

  1. 【認證服務器】youlai-auth 添加自定義客戶端信息獲取類;

    說明: 通過ClientDetailsServiceImpl#loadClientByClientId方法feign遠程獲取客戶端信息,后續版本計划添加多級緩存提升性能;

  2. 【認證服務器】youlai-auth 添加JWT生成器JwtGenerator;

    說明: 包含秘鑰庫加簽、設置有效期和增強,適用一些除OAuth2自帶常用的4種認證模式之外的一些特殊場景,目前暫不支持JWT續期,后續版本計划添加;

  3. 【資源服務器】youlai-gateway 添加本地公鑰加載方式;

    說明: 這里有個問題是比較多人問的,就是如何根據秘鑰庫生成公鑰,下文詳細說明;

  4. 【RBAC權限設計】請求接口權限和按鈕權限歸並在一條數據;

    說明:根據反饋大多數場景下前端如果設置了按鈕權限(顯示/隱藏),后端也需同時設置其接口權限攔截,可以算的上相輔相成的存在;

  5. 【表結構】 OAuth2官方表oauth_client_details重命名了sys_oauth_client

    說明:這個不要問,問就是強迫症,把OAuth2客戶端作為可管理的數據放在了系統管理部分,不重命名這張表就顯得很個性;

  6. 【依賴包升級】Spring Boot、Spring Cloud 、Spring Cloud Alibaba 、 Spring Security OAuth2等升級至最新版本, 具體最新版本源碼中查看;

    說明:其中要注意的是Spring Security OAuth2新版本認證接口不支持將客戶端信息(client_id/client_secret)放在請求路徑中,已經有多位小伙伴在使用Postman測試將其放在請求路徑中報了401的錯誤;

  7. 【API】根據系統管理端和小程序/APP端設置不同的前綴標識進行區分,系統管理端接口請求前綴標識使用/api,小程序端/APP端請求前綴標識使用/app-api

    說明:這樣設計目的在於一個微服務同時要給管理端和小程序端/APP同時提供不同的接口服務,其實這樣沒問題,但是系統管理端除了登錄還需要鑒權,小程序/APP端僅需要登錄,所以添加不同的標識區別。其實如果有資源和條件可以把系統管理服務接口和小程序/APP服務接口拆開來,這有點映照如果不是生活所迫,誰願意一身才華這句。

6. ToDoList

項目2.x版本計划事項

  • [ ] 多租戶

  • [ ] IM即時通訊(Netty/zookeeper/redis)

  • [ ] 商品搜索(ElasticSearch)

  • [ ] 移動端Android、IOS端適配(uni-app)

  • [ ] Vue2.x升級Vue3.x

  • [ ] 分布式鏈路追蹤(SkyWalking)

  • [ ] 多級緩存(商品/權限)

  • [ ] OAuth2授權碼模式

  • [ ] 分布式事務(Seata TCC模式)

  • [ ] 日志搜集(EFK)

  • [ ] ......

三. OAuth2認證授權

1. OAuth2的定義

OAuth2概念

以下摘自阮一峰老師的文章 OAuth 2.0 的一個簡單解釋

OAuth2.0是目前最流行的授權機制,用來授權第三方應用,獲取用戶數據。

簡單說,OAuth就是一種授權機制。數據的所有者告訴系統,同意授權第三方應用進入系統,獲取這些數據。系統從而產生一個短期的進入令牌(token),用來代替密碼,供第三方應用使用。

OAuth2角色

  • 資源擁有者(Resource Owner):用戶。
  • 第三方應用程序(Client):也稱為“客戶端”,客戶端需要資源服務器的資源(用戶信息)。
  • 認證服務器(Authorization Server):提供認證授權接口的服務器。
  • 資源服務器(Resource Server):第三方應用程序攜帶認證授權后的token再從資源服務器獲取資源(用戶信息),資源服務器有校驗token(驗簽還有時效性判斷)的能力。

注意: OAuth2所說的資源是用戶信息(ID,昵稱、性別、頭像等),而非微服務資源(商品服務、訂單服務等),需要區分開。

OAuth2流程

概念和角色定義這些比較模糊,接下來用【有來項目】演示下OAuth2整個流程,方便快速理解OAuth2,先看下整個項目架構流程圖

流程舉例:

用戶請求訂單服務(OAuth2客戶端)想獲取自己的訂單數據 ,但獲取訂單數據需要用戶的資源(比如用戶ID),所以需要先到認證中心(OAuth2認證服務器)去認證,認證通過后會返回JWT,接下來用戶攜帶JWT請求訂單服務,其中會經過網關(OAuth2資源服務器),網關驗證JWT是否有效,驗證有效則將攜帶着用戶資源的JWT傳遞給訂單服務,訂單服務拿到用戶ID之后即可獲取到用戶的訂單數據。

一般資源服務器和認證服務器是同一台服務器,但在這里將資源服務器從認證服務器分離到了網關,個人覺得主要是因為網關的特性,因為所有的服務訪問都必須經過網關,可以統一校驗JWT的有效性,通過后將攜帶用戶資源的JWT給對應的服務,同樣也是契合微服務的單一職責原則,降低耦合度。

2. OAuth2認證服務器

OAuth2認證服務器的職責很好理解,提供認證接口,認證通過后返回生成token,對應【有來項目】的youlai-auth認證中心。

認證接口及調試

很多剛接觸Spring Security OAuth2的小伙伴不知道其認證接口在哪里。所以這里稍微提一下認證endpoint是/oauth/token,【有來】中重寫此認證endpoint,位於AuthController#postAccessToken方法。

Postman認證接口調試

image-20210621075517108

Knife4j認證接口調試(牆裂推薦)

網關youlai-gateway啟動后,其服務端口是9999,然后訪問 http://localhost:9999/doc.html

點擊左側目錄的第二個節點Authorize填寫OAuth2的參數完成認證

image-20210622000304570

認證通過后,再點擊該微服務的其他接口,會將認證接口生成的token自動填充到請求頭中,非常方便和人性化

image-20210622000046029

核心代碼

這里只貼出認證中心youlai-auth關鍵部分代碼,完整代碼請從碼雲GiteeGithub獲取。

pom依賴
<dependency>
    <groupId>org.springframework.security.oauth.boot</groupId>
    <artifactId>spring-security-oauth2-autoconfigure</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
安全攔截配置
@Configuration
@EnableWebSecurity
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * Security接口攔截配置
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/oauth/**").permitAll()
            // @link https://gitee.com/xiaoym/knife4j/issues/I1Q5X6 (接口文檔knife4j需要放行的規則)
            .antMatchers("/webjars/**", "/doc.html", "/swagger-resources/**", "/v2/api-docs").permitAll()
            .anyRequest().authenticated()
            .and()
            .csrf().disable();
    }
    
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}
認證授權配置
@Configuration
@EnableAuthorizationServer
@AllArgsConstructor
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    private AuthenticationManager authenticationManager;
    private UserDetailsServiceImpl userDetailsService;
    private ClientDetailsServiceImpl clientDetailsService;

    /**
     * OAuth2客戶端【數據庫加載】
     */
    @Override
    @SneakyThrows
    public void configure(ClientDetailsServiceConfigurer clients) {
        clients.withClientDetails(clientDetailsService);
    }

    /**
     * 配置授權(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);
        endpoints
                .authenticationManager(authenticationManager)
                .accessTokenConverter(jwtAccessTokenConverter())
                .tokenEnhancer(tokenEnhancerChain)
                .userDetailsService(userDetailsService)
                // refresh token有兩種使用方式:重復使用(true)、非重復使用(false),默認為true
                //      1 重復使用:access token過期刷新時, refresh token過期時間未改變,仍以初次生成的時間為准
                //      2 非重復使用:access token過期刷新時, refresh token過期時間延續,在refresh token有效期內刷新便永不失效達到無需再次登錄的目的
                .reuseRefreshTokens(true);
    }

    /**
     * 使用非對稱加密算法對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("jwt.jks"), "123456".toCharArray());
        KeyPair keyPair = factory.getKeyPair("jwt", "123456".toCharArray());
        return keyPair;
    }

    /**
     * JWT內容增強
     */
    @Bean
    public TokenEnhancer tokenEnhancer() {
        return (accessToken, authentication) -> {
            Map<String, Object> additionalInfo = CollectionUtil.newHashMap();
            OAuthUserDetails OAuthUserDetails = (OAuthUserDetails) authentication.getUserAuthentication().getPrincipal();
            additionalInfo.put("userId", OAuthUserDetails.getId());
            additionalInfo.put("username", OAuthUserDetails.getUsername());
            ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
            return accessToken;
        };
    }

    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setHideUserNotFoundExceptions(false); // 用戶不存在異常拋出
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    /**
     * 密碼編碼器
     * 委托方式,根據密碼的前綴選擇對應的encoder,例如:{bcypt}前綴->標識BCYPT算法加密;{noop}->標識不使用任何加密即明文的方式
     * 密碼判讀 DaoAuthenticationProvider#additionalAuthenticationChecks
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

認證授權配置類主要實現功能:

  1. 指定構建用戶認證信息UserDetailsService為UserDetailsServiceImpl,從數據庫獲取用戶信息和前端傳值進行密碼判讀
  2. 指定構建客戶端認證信息ClientDetailsService為ClientDetailsServiceImpl,從數據庫獲取客戶端信息和前端傳值進行密碼判讀
  3. JWT加簽,從密鑰庫獲取密鑰對完成對JWT的簽名,密鑰庫如何生成下文細說
  4. JWT增強

UserDetailService自定義實現加載用戶認證信息

@Service
@AllArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private UserFeignClient userFeignClient;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String clientId = JwtUtils.getAuthClientId();
        OAuthClientEnum client = OAuthClientEnum.getByClientId(clientId);

        Result result;
        OAuthUserDetails oauthUserDetails = null;
        switch (client) {
            default:
                result = userFeignClient.getUserByUsername(username);
                if (ResultCode.SUCCESS.getCode().equals(result.getCode())) {
                    SysUser sysUser = (SysUser)result.getData();
                    oauthUserDetails = new OAuthUserDetails(sysUser);
                }
                break;
        }
        if (oauthUserDetails == null || oauthUserDetails.getId() == null) {
            throw new UsernameNotFoundException(ResultCode.USER_NOT_EXIST.getMsg());
        } else if (!oauthUserDetails.isEnabled()) {
            throw new DisabledException("該賬戶已被禁用!");
        } else if (!oauthUserDetails.isAccountNonLocked()) {
            throw new LockedException("該賬號已被鎖定!");
        } else if (!oauthUserDetails.isAccountNonExpired()) {
            throw new AccountExpiredException("該賬號已過期!");
        }
        return oauthUserDetails;
    }
}

ClientDetailsService自定義加載客戶端認證信息

@Service
@AllArgsConstructor
public class ClientDetailsServiceImpl implements ClientDetailsService {

    private OAuthClientFeignClient oAuthClientFeignClient;

    @Override
    @SneakyThrows
    public ClientDetails loadClientByClientId(String clientId) {
        try {
            Result<SysOauthClient> result = oAuthClientFeignClient.getOAuthClientById(clientId);
            if (Result.success().getCode().equals(result.getCode())) {
                SysOauthClient client = result.getData();
                BaseClientDetails clientDetails = new BaseClientDetails(
                        client.getClientId(),
                        client.getResourceIds(),
                        client.getScope(),
                        client.getAuthorizedGrantTypes(),
                        client.getAuthorities(),
                        client.getWebServerRedirectUri());
                clientDetails.setClientSecret(PasswordEncoderTypeEnum.NOOP.getPrefix() + client.getClientSecret());
                return clientDetails;
            } else {
                throw new NoSuchClientException("No client with requested id: " + clientId);
            }
        } catch (EmptyResultDataAccessException var4) {
            throw new NoSuchClientException("No client with requested id: " + clientId);
        }
    }
}

生成密鑰庫

生成密鑰庫腳本命令

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

參數說明

-alias 別名
-keyalg 密鑰算法
-keypass 密鑰口令
-keystore 生成密鑰庫的存儲路徑和名稱
-storepass 密鑰庫口令

3. OAuth2資源服務器

OAuth2資源服務器是提供給客戶端資源的服務器,有驗證token的能力,token有效則放開資源,對應【有來項目】的youlai-gateway網關。

核心代碼

這里只貼出網關youlai-gateway關鍵部分代碼,完整代碼請從碼雲GiteeGithub獲取。

pom依賴
<!-- OAuth2資源服務器-->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
統一鑒權管理器

微服務項目最終對外暴露的只有網關服務一個端口,其他微服務端口不對外暴露,所有的請求都會經過網關路由轉發到內網微服務上,所以網關是進行接口訪問權限校驗最好的實踐地。

原因有以下:

  1. 降低開發成本,不必為每個微服務單獨引入Security模塊,專注業務模塊的開發;
  2. 縮短訪問鏈路,無權訪問的請求直接在網關被攔截;
  3. 統一入口,統一攔截。

不過網關鑒權有個需注意的地方,因為項目API設計遵守RESTful接口設計規范,基於RESTful然后我舉個例子說,給你一個/youlai-admin/users/1請求路徑,你沒法判斷是獲取ID為1的用戶信息還是修改ID為1的用戶信息,怎么辦?

所以將請求方法和請求路徑結合生成restfulPath = GET:/youlai-admin/users/1,這樣系統就可以進行區分,在設置權限攔截規則的時候需要考慮到,具體的在下文的RBAC權限設計詳細說,這里暫只貼出網關鑒權的邏輯代碼。

/**
 * 網關自定義鑒權管理器
 *
 * @author <a href="mailto:xianrui0365@163.com">xianrui</a>
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class ResourceServerManager implements ReactiveAuthorizationManager<AuthorizationContext> {

    private final RedisTemplate redisTemplate;

    private final UrlPermRolesLocalCache urlPermRolesLocalCache;

    // 是否開啟本地緩存
    @Value("${local-cache.enabled}")
    private Boolean localCacheEnabled;

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
        ServerHttpRequest request = authorizationContext.getExchange().getRequest();
        // 預檢請求放行
        if (request.getMethod() == HttpMethod.OPTIONS) {
            return Mono.just(new AuthorizationDecision(true));
        }
        PathMatcher pathMatcher = new AntPathMatcher(); // Ant匹配器
        String method = request.getMethodValue();
        String path = request.getURI().getPath();
        String restfulPath = method + ":" + path; // Restful接口權限設計 @link https://www.cnblogs.com/haoxianrui/p/14961707.html


        // 移動端請求需認證但無需鑒權判斷
        String token = request.getHeaders().getFirst(AuthConstants.AUTHORIZATION_KEY);
        if (pathMatcher.match(GlobalConstants.APP_API_PATTERN, path)) {
            // 如果token以"bearer "為前綴,到這里說明JWT有效即已認證
            if (StrUtil.isNotBlank(token)
                    && token.startsWith(AuthConstants.AUTHORIZATION_PREFIX)) {
                return Mono.just(new AuthorizationDecision(true));
            } else {
                return Mono.just(new AuthorizationDecision(false));
            }
        }

        // 緩存取 URL權限-角色集合 規則數據
        // urlPermRolesRules = [{'key':'GET:/api/v1/users/*','value':['ADMIN','TEST']},...]
        Map<String, Object> urlPermRolesRules;
        if (localCacheEnabled) {
            urlPermRolesRules = (Map<String, Object>) urlPermRolesLocalCache.getCache(GlobalConstants.URL_PERM_ROLES_KEY);
            if (null == urlPermRolesRules) {
                urlPermRolesRules = redisTemplate.opsForHash().entries(GlobalConstants.URL_PERM_ROLES_KEY);
                urlPermRolesLocalCache.setLocalCache(GlobalConstants.URL_PERM_ROLES_KEY, urlPermRolesRules);
            }
        } else {
            urlPermRolesRules = redisTemplate.opsForHash().entries(GlobalConstants.URL_PERM_ROLES_KEY);
        }


        // 根據請求路徑判斷有訪問權限的角色列表
        List<String> authorizedRoles = new ArrayList<>(); // 擁有訪問權限的角色
        boolean requireCheck = false; // 是否需要鑒權,默認“沒有設置權限規則”不用鑒權

        for (Map.Entry<String, Object> permRoles : urlPermRolesRules.entrySet()) {
            String perm = permRoles.getKey();
            if (pathMatcher.match(perm, restfulPath)) {
                List<String> roles = Convert.toList(String.class, permRoles.getValue());
                authorizedRoles.addAll(Convert.toList(String.class, roles));
                if (requireCheck == false) {
                    requireCheck = true;
                }
            }
        }
        if (requireCheck == false) {
            return Mono.just(new AuthorizationDecision(true));
        }

        // 判斷JWT中攜帶的用戶角色是否有權限訪問
        Mono<AuthorizationDecision> authorizationDecisionMono = mono
                .filter(Authentication::isAuthenticated)
                .flatMapIterable(Authentication::getAuthorities)
                .map(GrantedAuthority::getAuthority)
                .any(authority -> {
                    String roleCode = authority.substring(AuthConstants.AUTHORITY_PREFIX.length()); // 用戶的角色
                    if (GlobalConstants.ROOT_ROLE_CODE.equals(roleCode)) {
                        return true; // 如果是超級管理員則放行
                    }
                    boolean hasAuthorized = CollectionUtil.isNotEmpty(authorizedRoles) && authorizedRoles.contains(roleCode);
                    return hasAuthorized;
                })
                .map(AuthorizationDecision::new)
                .defaultIfEmpty(new AuthorizationDecision(false));
        return authorizationDecisionMono;
    }
}

資源服務器配置
@ConfigurationProperties(prefix = "security")
@AllArgsConstructor
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {

    private ResourceServerManager resourceServerManager;

    @Setter
    private List<String> ignoreUrls;

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter())
                .publicKey(rsaPublicKey()) // 本地獲取公鑰
                //.jwkSetUri() // 遠程獲取公鑰
        ;
        http.oauth2ResourceServer().authenticationEntryPoint(authenticationEntryPoint());
        http.authorizeExchange()
                .pathMatchers(Convert.toStrArray(ignoreUrls)).permitAll()
                .anyExchange().access(resourceServerManager)
                .and()
                .exceptionHandling()
                .accessDeniedHandler(accessDeniedHandler()) // 處理未授權
                .authenticationEntryPoint(authenticationEntryPoint()) //處理未認證
                .and().csrf().disable();

        return http.build();
    }

    /**
     * 未授權自定義響應
     */
    @Bean
    ServerAccessDeniedHandler accessDeniedHandler() {
        return (exchange, denied) -> {
            Mono<Void> mono = Mono.defer(() -> Mono.just(exchange.getResponse()))
                    .flatMap(response -> ResponseUtils.writeErrorInfo(response, ResultCode.ACCESS_UNAUTHORIZED));
            return mono;
        };
    }

    /**
     * token無效或者已過期自定義響應
     */
    @Bean
    ServerAuthenticationEntryPoint authenticationEntryPoint() {
        return (exchange, e) -> {
            Mono<Void> mono = Mono.defer(() -> Mono.just(exchange.getResponse()))
                    .flatMap(response -> ResponseUtils.writeErrorInfo(response, ResultCode.TOKEN_INVALID_OR_EXPIRED));
            return mono;
        };
    }

    /**
     * @return
     * @link https://blog.csdn.net/qq_24230139/article/details/105091273
     * ServerHttpSecurity沒有將jwt中authorities的負載部分當做Authentication
     * 需要把jwt的Claim中的authorities加入
     * 方案:重新定義權限管理器,默認轉換器JwtGrantedAuthoritiesConverter
     */
    @Bean
    public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstants.AUTHORITY_PREFIX);
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstants.JWT_AUTHORITIES_KEY);

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

    /**
     * 本地加載JWT驗簽公鑰
     * @return
     */
    @SneakyThrows
    @Bean
    public RSAPublicKey rsaPublicKey() {
        Resource resource = new ClassPathResource("public.key");
        InputStream is = resource.getInputStream();
        String publicKeyData = IoUtil.read(is).toString();
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec((Base64.decode(publicKeyData)));

        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        RSAPublicKey rsaPublicKey = (RSAPublicKey)keyFactory.generatePublic(keySpec);
        return rsaPublicKey;
    }
}

資源服務器配置類主要實現功能:

  1. 配置訪問白名單列表 ignoreUrls,白名單請求無需認證和鑒權;
  2. 配置本地方式獲取公鑰或者遠程獲取公鑰,公鑰驗證JWT的簽名,其中本地公鑰方式【有來項目】2.0版本新增;
  3. 配置未授權、token無效或者已過期的自定義異常。
  http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter())
                .publicKey(rsaPublicKey()) // 本地獲取公鑰
                //.jwkSetUri() // 遠程獲取公鑰
        ;

OAuth2資源服務器(網關)在對JWT驗簽的時候需要使用公鑰,通過上面代碼可以看到加載公鑰有兩種方式,分為本地和遠程兩種方式,下面就兩種方式如何實現進行說明,同時也補充下版本2.0新增的本地加載公鑰方式中公鑰是怎么根據密鑰庫生成的。

遠程加載公鑰

認證中心youlai-auth添加獲取公鑰接口

    @ApiOperation(value = "獲取公鑰", notes = "login")
    @GetMapping("/public-key")
    public Map<String, Object> getPublicKey() {
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAKey key = new RSAKey.Builder(publicKey).build();
        return new JWKSet(key).toJSONObject();
    }

網關youlai-gateway配置公鑰的遠程請求地址

spring:
 security:
   oauth2: 
     resourceserver:
       jwt:
         jwk-set-uri: 'http://localhost:9999/youlai-auth/oauth/public-key'
本地加載公鑰
    /**
     * 本地加載JWT驗簽公鑰
     * @return
     */
    @SneakyThrows
    @Bean
    public RSAPublicKey rsaPublicKey() {
        Resource resource = new ClassPathResource("public.key");
        InputStream is = resource.getInputStream();
        String publicKeyData = IoUtil.read(is).toString();
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec((Base64.decode(publicKeyData)));

        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        RSAPublicKey rsaPublicKey = (RSAPublicKey)keyFactory.generatePublic(keySpec);
        return rsaPublicKey;
    }

本地加載方式第一步是加載類路徑下的公鑰pulic.key,那么這個公鑰是怎么生成的?

image-20210627181749942

生成公鑰

其實有關公鑰的生成,Github項目中一個issue有詳細的描述 https://github.com/hxrui/youlai-mall/issues/27

image-20210627183009564

在這里補充下其詳細生成過程

首先訪問 http://slproweb.com/products/Win32OpenSSL.html 下載OpenSSL ,根據系統選擇對應版本

image-20210627211554758

添加OpenSSL安裝后的bin路徑如D:\Program Files\OpenSSL-Win64\bin 至系統環境變量path中

cmd切換到密鑰庫jwt.jks所在路徑中,執行keytool -list -rfc --keystore jwt.jks | openssl x509 -inform pem -pubkey

輸入密鑰庫口令就可以看到生成的公鑰,將內容復制到pulic.key文件即可

重新生成密鑰庫后,項目需mvn clean,同步更新公鑰內容,否則token驗簽過不了報token無效

四. 網關統一鑒權

在上一章節提到網關是所有微服務請求的入口,在這里進行統一鑒權是不二之選;不過針對RESTful接口統一鑒權的情況,配置攔截路徑的規則需攜帶請求方法加以區別。

接下來就【有來項目】中如何實現Spring Cloud Gateway + RESTful接口統一攔截鑒權而進行的權限設計進行說明。

1. RBAC權限模型

RBAC(Role-Based Access Control)基於角色訪問控制,目前使用最為廣泛的權限模型。

此模型有三個角色用戶、角色和權限,在傳統的權限模型用戶直接關聯加了角色層,解耦了用戶和權限,使得權限系統有了更清晰的職責划分和更高的靈活度。

這種RBAC權限設計和市面上大差不差,區別的是sys_permission權限表的設計:

  1. 權限表中的menu_id字段標識該權限屬於某個菜單模塊,僅方便模塊管理,無強關聯;
  2. 權限標識分為接口權限標識url_perm和按鈕權限標識btn_perm,網關只能根據請求路徑去鑒權,和按鈕的權限標識區別很大。

先看下sys_permission權限表的數據,比較下接口權限標識(url_perm)和按鈕權限(btn_perm)標識的區別

2. 權限管理

添加菜單

進入菜單管理頁面,進入表單頁面,可以看到這是針對vue-router路由做的菜單設計,系統實現了動態權限路由加載以及路由兩種編程式跳轉

image-20210628220000953

添加權限

首先選擇菜單,右側關聯加載出權限數據,注意這里的關聯只是方便權限模塊化管理,無實際關聯設計

image-20210628202709350

設置URL權限攔截規則,因為是RESTful的接口設計,所以規則中需攜帶請求Method,在網關鑒權使用Ant匹配器,下圖中的*匹配任意參數

image-20210628221001803

角色授權

進入角色管理頁面,點擊選擇角色→選擇菜單→加載權限,勾選設置

3. 權限驗證

上面設置系統管理員有用戶管理、角色管理、菜單管理3個菜單和查看用戶和編輯用戶2個接口和按鈕權限,刷新頁面后如下,可以看到頁面只有3個菜單,並且新增和刪除按鈕未在頁面顯示

image-20210628231840245

添加部門菜單,但未授權查詢部門列表權限,刷新頁面看到部門管理菜單出現了

點擊部門管理菜單請求部門分頁列表接口時,提示訪問未授權,即接口攔截規則生效

image-20210629005806367

4. 權限實現原理

接口權限

  • 權限規則數據

在系統管理完成對接口權限的設置,先看下數據庫的權限數據

  • 權限規則數據加載至緩存

    因為權限數據使用頻率高但變化頻率不高,目前將其加載至Redis緩存,后續添加本地緩存的多級緩存策略進行優化

/**
 * Spring容器啟動完成時加載權限規則至Redis緩存
 */
@Component
@AllArgsConstructor
public class InitPermissionRoles implements CommandLineRunner {

    private ISysPermissionService iSysPermissionService;

    @Override
    public void run(String... args) {
        iSysPermissionService.refreshPermRolesRules();
    }
}

具體加載詳見源碼,加載完成后在Redis呈現出來的數據如下

  • 網關鑒權代碼調試

    接下來就是關鍵部分了,之前無論RBAC權限設計、還是管理平台的操作和權限規則緩存加載等都是為了網關統一鑒權做的准備工作

    當請求到網關時,如果有配置權限攔截規則但未配置白名單的請求需要走鑒權的邏輯,下面通過代碼調試來看下鑒權過程:

    1. 進入ResourceServerManager#check方法,網關鑒權開始,這里拿系統管理員(ADMIN)訪問部門列表接口舉例

    2. 根據請求方法和請求路徑拼接自定義的 restfulPath = GET:/youlai-admin/api/v1/depts

    image-20210629220652681

    1. 從Redis緩存讀取權限規則,可以看到權限規則列表中有匹配部門列表接口的規則

    image-20210629223806369

    1. 從權限規則中獲取有部門列表接口權限的角色,可以看到有權限的角色集合並沒有ADMIN

    image-20210629230110424

    1. 最后一步,拿當前用戶JWT攜帶的角色和擁有權限的角色進行匹配,只要有一個匹配,就說明用戶擁有訪問權限則放行,但上面的結果可想而知,系統管理員並沒有部門列表接口的訪問權限,則鑒權不通過被攔截

    image-20210629231758635

按鈕權限

  • 按鈕權限實現原理

    按鈕權限控制的核心是Vue自定義指令,Vue除了內置指令有v-model 、v-if和v-show等,同樣也支持注冊自定義指令作用在元素上。

    項目中使用Vue.directive注冊自定義指令v-has-permission來判斷當前登錄用戶是否擁有按鈕權限。看下圖就明白如何應用的:

    image-20210629234240314

  • Vue自定義指令

    如何自定義Vue指令並注冊成全局指令呢?其實vue-element-admin已自定義過很多的指令,僅需跟着照葫蘆畫瓢就行。

    1. src/directive/permission路徑添加hasPermission.js文件,編寫按鈕權限控制代碼邏輯

    image-20210629235815182

    1. 注冊v-has-permission全局指令,在main.js注冊成全局指

      image-20210630000021599

    2. 按鈕元素使用自定義指令

      image-20210629234240314

    3. 最后提一下,用戶是在登錄成功的時候獲取用戶信息時拿到的按鈕權限標識集合

      image-20210630000847176

五. 常見問題

收集一些項目的issue和被常見的問題。

  1. 啟動網關GatewayApplication報錯,Error:Kotlin: Module was compiled with an incompatible version of Kotlin.

    IDEA禁用Kotlin插件

  2. Mybatis參數和請求參數注解報錯

    IDEA版本升級

  3. token無效或已過期

    • 進入https://jwt.io/ 解析JWT查看是否過期
    • 是否更換過密鑰庫jwt.jks,如果更換網關本地需同步更新公鑰內容public.key,執行mvn clean再啟動項目
    • 源碼調試分析,JWT解析源碼坐標:NimbusReactiveJwtDecoder#decode;JWT過期校驗源碼坐標:JwtTimestampValidator#validate
  4. OAuth2認證授權報錯,401 Unauthorized

    客戶端信息錯誤,新版本Spring Security OAuth2不支持客戶端信息(client_id/client_secret)放入請求路徑,Base64加密后放在請求頭

  5. 認證中心Security已配置放行,還是進入不到/oauth/token接口

    這個問題和上面的問題都可以在過濾器BasicAuthenticationFilter#doFilterInternal方法添加斷點調試分析

  6. Cannot load keys from store: class path resource [xxx.jks]

    • 檢查獲取KeyPair密鑰對的時候輸入的密鑰庫密碼是否正確
    • 更換密鑰庫jwt.jks的同時網關需同步更新公鑰內容public.key,執行mvn clean再啟動項目
  7. 密碼或用戶名錯誤

    源碼調試分析,密碼判斷源碼坐標:DaoAuthenticationProvider#additionalAuthenticationChecks

    image-20210630074125767

  8. 前端工程npm install報錯

    • 本地是否安裝git
    • 請確認有個好的網絡環境,需從GitHub下載依賴
  9. 項目中使的用自動代碼生成工具

    MybatisX,Mybatis-Plus官方推薦的IDEA插件,優勢在於零配置實現MyBatis-Plus的代碼生成,也支持Lombok,如果項目使用Mybatis-Plus,比較推薦

    image-20210630080808817

  10. Maven依賴包缺失

  • 配置阿里雲遠程倉庫,settings.xml找到 標簽替換為以下內容

      <mirror>
    	  <id>alimaven</id>
    	  <name>aliyun maven</name>
    	  <url>http://maven.aliyun.com/nexus/content/groups/public/</url>
    	  <mirrorOf>central</mirrorOf>        
      </mirror>
    
  • 刪除本地倉庫重新下載依賴至本地倉庫

  1. OAuth2的認證授權接口請求頭Basic是怎么得到

    訪問在線base64編碼

    image-20210630231334492

六. 寫在最后

本篇內容主要涉及OAuth2認證授權模式的原理以及應用,嚴格遵守微服務單一職責的設計原則,將資源服務器從認證服務器拆分出來,讓認證服務器(認證中心)統一負責認證授權,資源服務器(網關)統一處理鑒權,做到功能上的高度解耦。基於RBAC權限模型設計一套適配微服務+前后端分離開發模式的權限框架,在網關統一鑒權的設計基礎上實現了對RESTful規范接口的細粒度鑒權;借助vue.directive自定義指令實現頁面的按鈕權限控制。總之,【有來】不僅僅是表面上的全棧商城項目,也是一套集成當下主流開發模式、主流技術棧的完整的微服務腳手架項目,沒有過度的自定義封裝邏輯,容易上手學習和方便二次擴展。最后希望各位道友多多關注開源項目的進展,一起加油,如果項目中遇到問題或者有什么建議,歡迎聯系我們。

七. 聯系信息

因為微信交流群超過200人,只能通過邀請方式進入,如果項目中遇到什么問題或者想進入交流群學習的朋友請添加開發人員后由其拉進群,備注“有來”即可。

【有來小店】微信小程序體驗碼 進群加我,我發邀請連接,備注“有來”即可


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM