一. 前言

hi,大家好~ 好久沒更文了,期間主要致力於專案的功能升級和問題修復中,經過一年時間的打磨,【有來】終於迎來v2.0版本,相較於v1.x版本主要完善了OAuth2認證授權、鑑權的邏輯,結合小夥伴提出來的建議,。

寫這篇文章的除了對一年來專案的階段性總結,也是希望幫助大家快速理解當下流行的OAuth2認證授權模式,以及其在當下主流的微服務+前後端分離開發模式(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

3. 專案預覽

線上預覽地址

地址: www.youlai.tech 使用者名稱/密碼:admin/123456

系統管理端

微信小程式

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.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,位於OAuthController#postAccessToken方法。

Postman認證介面除錯

Knife4j認證介面除錯(牆裂推薦)

閘道器youlai-gateway啟動後,其服務埠是9999,然後訪問 http://localhost:9999/doc.html

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

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

核心程式碼

這裡只貼出關鍵部分程式碼,完整程式碼請從碼雲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閘道器。

核心程式碼

這裡只貼出關鍵部分程式碼,完整程式碼請從碼雲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許可權設計詳細說,這裡暫只貼出閘道器鑑權的邏輯程式碼。

@Component
@AllArgsConstructor
@Slf4j
public class ResourceServerManager implements ReactiveAuthorizationManager<AuthorizationContext> { private RedisTemplate redisTemplate; @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路徑匹配模式,“請求路徑”和快取中許可權規則的“URL許可權標識”匹配
String path = request.getURI().getPath(); String token = request.getHeaders().getFirst(AuthConstants.AUTHORIZATION_KEY); // 移動端請求無需鑑權,只需認證(即JWT的驗籤和是否過期判斷)
if (pathMatcher.match(GlobalConstants.APP_API_PATTERN, path)) {
// 如果token以"bearer "為字首,到這一步說明是經過NimbusReactiveJwtDecoder#decode和JwtTimestampValidator#validate等解析和驗證通過的,即已認證
if (StrUtil.isNotBlank(token) && token.startsWith(AuthConstants.AUTHORIZATION_PREFIX)) {
return Mono.just(new AuthorizationDecision(true));
} else {
return Mono.just(new AuthorizationDecision(false));
}
} // Restful介面許可權設計 @link https://www.cnblogs.com/haoxianrui/p/14396990.html
String restfulPath = request.getMethodValue() + ":" + path;
log.info("請求方法:RESTFul請求路徑:{}", restfulPath); // 快取取【URL許可權標識->角色集合】許可權規則
Map<String, Object> permRolesRules = redisTemplate.opsForHash().entries(GlobalConstants.URL_PERM_ROLES_KEY); // 根據 “請求路徑” 和 許可權規則中的“URL許可權標識”進行Ant匹配,得出擁有許可權的角色集合
Set<String> hasPermissionRoles = CollectionUtil.newHashSet(); // 【宣告定義】有許可權的角色集合
boolean needToCheck = false; // 【宣告定義】是否需要被攔截檢查的請求,如果快取中許可權規則中沒有任何URL許可權標識和此次請求的URL匹配,預設不需要被鑑權 for (Map.Entry<String, Object> permRoles : permRolesRules.entrySet()) {
String perm = permRoles.getKey(); // 快取許可權規則的鍵:URL許可權標識
if (pathMatcher.match(perm, restfulPath)) {
List<String> roles = Convert.toList(String.class, permRoles.getValue()); // 快取許可權規則的值:有請求路徑訪問許可權的角色集合
hasPermissionRoles.addAll(Convert.toList(String.class, roles));
if (needToCheck == false) {
needToCheck = true;
}
}
}
// 沒有設定許可權規則放行;注:如果預設想攔截所有的請求請移除needToCheck變數邏輯即可,根據需求定製
if (needToCheck == false) {
return Mono.just(new AuthorizationDecision(true));
} // 判斷使用者JWT中攜帶的角色是否有能通過許可權攔截的角色
Mono<AuthorizationDecision> authorizationDecisionMono = mono
.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
.any(authority -> {
log.info("使用者許可權(角色) : {}", authority); // ROLE_ROOT
String role = authority.substring(AuthConstants.AUTHORITY_PREFIX.length()); // 角色編碼 ROOT
if (GlobalConstants.ROOT_ROLE_CODE.equals(role)) { // 如果是超級管理員則放行
return true;
}
return CollectionUtil.isNotEmpty(hasPermissionRoles) && hasPermissionRoles.contains(role); // 使用者角色中只要有一個滿足則通過許可權校驗
})
.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.0版本新增;
  3. 配置未授權、token無效或者已過期的自定義異常。
  http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter())
.publicKey(rsaPublicKey()) // 本地獲取公鑰
//.jwkSetUri() // 遠端獲取公鑰
;

OAuth2資源伺服器(閘道器)在對JWT驗籤的時候需要使用公鑰,通過上面程式碼可以看到載入公鑰有兩種方式,分為本地和遠端兩種方式,下面就兩種方式如何實現進行說明,同時也補充下版本2.0.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,那麼這個公鑰是怎麼生成的?

生成公鑰

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

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

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

新增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路由做的選單設計,系統實現了動態許可權路由載入以及路由兩種程式設計式跳轉

新增許可權

首先選擇選單,右側關聯加載出許可權資料,注意這裡的關聯只是方便許可權模組化管理,無實際關聯設計

設定URL許可權攔截規則,因為是RESTful的介面設計,所以規則中需攜帶請求Method,在閘道器鑑權使用Ant匹配器,下圖中的*匹配任意引數

角色授權

進入角色管理頁面,點選選擇角色→選擇選單→載入許可權,勾選設定

3. 許可權驗證

上面設定系統管理員有使用者管理、角色管理、選單管理3個選單和檢視使用者和編輯使用者2個介面和按鈕許可權,重新整理頁面後如下,可以看到頁面只有3個選單,並且新增和刪除按鈕未在頁面顯示

新增部門選單,但未授權查詢部門列表許可權,重新整理頁面看到部門管理選單出現了

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

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

    1. 從Redis快取讀取許可權規則,可以看到許可權規則列表中有匹配部門列表介面的規則

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

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

按鈕許可權

  • 按鈕許可權實現原理

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

    專案中使用Vue.directive註冊自定義指令v-has-permission來判斷當前登入使用者是否擁有按鈕許可權。看下圖就明白如何應用的:

  • Vue自定義指令

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

    1. src/directive/permission路徑新增hasPermission.js檔案,編寫按鈕許可權控制程式碼邏輯

    1. 註冊v-has-permission全域性指令,在main.js註冊成全域性指

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

    3. 最後提一下,使用者是在登入成功的時候獲取使用者資訊時拿到的按鈕許可權標識集合

五. 常見問題

收集一些專案的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

  8. 前端工程npm install報錯

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

    MybatisX,Mybatis-Plus官方推薦的IDEA外掛,優勢在於零配置實現MyBatis-Plus的程式碼生成,也支援Lombok,如果專案使用Mybatis-Plus,比較推薦

  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編碼

六. 寫在最後

本篇內容主要涉及OAuth2認證授權模式的原理以及應用,嚴格遵守微服務單一職責的設計原則,將資源伺服器從認證伺服器拆分出來,讓認證伺服器(認證中心)統一負責認證授權,資源伺服器(閘道器)統一處理鑑權,做到功能上的高度解耦。基於RBAC許可權模型設計一套適配微服務+前後端分離開發模式的許可權框架,在閘道器統一鑑權的設計基礎上實現了對RESTful規範介面的細粒度鑑權;藉助vue.directive自定義指令實現頁面的按鈕許可權控制。總之,【有來】不僅僅是表面上的全棧商城專案,也是一套整合當下主流開發模式、主流技術棧的完整的微服務腳手架專案,沒有過度的自定義封裝邏輯,容易上手學習和方便二次擴充套件。最後希望各位道友多多關注開源專案的進展,一起加油,如果專案中遇到問題或者有什麼建議,歡迎聯絡我們。因為微信交流群超過200人了,只能通過邀請進入群聊,新增我的微信(haoxianrui)後我拉您進群,相互交流學習,備註“有來”即可。