【Spring Cloud & Alibaba 實戰 | 總結篇】Spring Cloud Gateway + Spring Security OAuth2 + JWT 實現微服務統一認證授權和鑒權
- 2021 年 7 月 2 日
- 筆記
- Spring Cloud
一. 前言
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 實戰
- Spring Cloud實戰 | 第一篇:Windows搭建Nacos服務
- Spring Cloud實戰 | 第二篇:Spring Cloud整合Nacos實現註冊中心
- Spring Cloud實戰 | 第三篇:Spring Cloud整合Nacos實現配置中心
- Spring Cloud實戰 | 第四篇:Spring Cloud整合Gateway實現API網關
- Spring Cloud實戰 | 第五篇:Spring Cloud整合OpenFeign實現微服務之間的調用
- Spring Cloud實戰 | 第六篇:Spring Cloud Gateway+Spring Security OAuth2+JWT實現微服務統一認證授權
- Spring Cloud實戰 | 最七篇:Spring Cloud Gateway+Spring Security OAuth2認證授權模式註銷JWT失效方案
- Spring Cloud實戰 | 最八篇:Spring Cloud + Spring Security OAuth2+ Vue前後端分離模式下無感知刷新實現JWT續期
- Spring Cloud實戰 | 最九篇:Spring Cloud + Spring Security OAuth2認證伺服器統一認證自定義異常處理
- Spring Cloud實戰 | 第十篇 : Spring Cloud + Nacos整合Seata 1.4.1實現分散式事務
- Spring Cloud實戰 | 第十一篇:Spring Cloud Gateway統一鑒權下針對RESTful介面的RBAC許可權設計方案,附Vue按鈕許可權控制
- Spring Cloud & Alibaba 實戰 | 第十二篇: Sentinel+Nacos實現網關和普通流控、熔斷降級
- vue + element-ui實戰
- vue-element-admin實戰 | 第一篇: 移除mock接入微服務介面,搭建Spring Cloud+Vue前後端分離管理平台
- vue-element-admin實戰 | 第二篇: 最小改動接入後台介面實現根據許可權動態載入菜單
- uni-app 實戰
5. 版本升級
此次升級2.0.0版本主要內容和說明整理如下:
-
【認證伺服器】youlai-auth 添加自定義客戶端資訊獲取類;
說明: 通過
ClientDetailsServiceImpl
#loadClientByClientId
方法feign遠程獲取客戶端資訊,後續版本計劃添加多級快取提升性能; -
【認證伺服器】youlai-auth 添加JWT生成器JwtGenerator;
說明: 包含秘鑰庫加簽、設置有效期和增強,適用一些除OAuth2自帶常用的4種認證模式之外的一些特殊場景,目前暫不支援JWT續期,後續版本計劃添加;
-
【資源伺服器】youlai-gateway 添加本地公鑰載入方式;
說明: 這裡有個問題是比較多人問的,就是如何根據秘鑰庫生成公鑰,下文詳細說明;
-
【RBAC許可權設計】請求介面許可權和按鈕許可權歸併在一條數據;
說明:根據回饋大多數場景下前端如果設置了按鈕許可權(顯示/隱藏),後端也需同時設置其介面許可權攔截,可以算的上相輔相成的存在;
-
【表結構】 OAuth2官方表
oauth_client_details
重命名了sys_oauth_client
;說明:這個不要問,問就是強迫症,把OAuth2客戶端作為可管理的數據放在了系統管理部分,不重命名這張表就顯得很個性;
-
【依賴包升級】Spring Boot、Spring Cloud 、Spring Cloud Alibaba 、 Spring Security OAuth2等升級至最新版本, 具體最新版本源碼中查看;
說明:其中要注意的是Spring Security OAuth2新版本認證介面不支援將客戶端資訊(client_id/client_secret)放在請求路徑中,已經有多位小夥伴在使用Postman測試將其放在請求路徑中報了401的錯誤;
-
【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,然後訪問 //localhost:9999/doc.html
點擊左側目錄的第二個節點Authorize填寫OAuth2的參數完成認證
認證通過後,再點擊該微服務的其他介面,會將認證介面生成的token自動填充到請求頭中,非常方便和人性化
核心程式碼
這裡只貼出關鍵部分程式碼,完整程式碼請從碼雲Gitee或Github獲取。
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 //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();
}
}
認證授權配置類主要實現功能:
- 指定構建用戶認證資訊UserDetailsService為UserDetailsServiceImpl,從資料庫獲取用戶資訊和前端傳值進行密碼判讀
- 指定構建客戶端認證資訊ClientDetailsService為ClientDetailsServiceImpl,從資料庫獲取客戶端資訊和前端傳值進行密碼判讀
- JWT加簽,從密鑰庫獲取密鑰對完成對JWT的簽名,密鑰庫如何生成下文細說
- 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網關。
核心程式碼
這裡只貼出關鍵部分程式碼,完整程式碼請從碼雲Gitee或Github獲取。
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>
統一鑒權管理器
微服務項目最終對外暴露的只有網關服務一個埠,其他微服務埠不對外暴露,所有的請求都會經過網關路由轉發到內網微服務上,所以網關是進行介面訪問許可權校驗最好的實踐地。
原因有以下:
- 降低開發成本,不必為每個微服務單獨引入Security模組,專註業務模組的開發;
- 縮短訪問鏈路,無權訪問的請求直接在網關被攔截;
- 統一入口,統一攔截。
不過網關鑒權有個需注意的地方,因為項目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 //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 //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;
}
}
資源伺服器配置類主要實現功能:
- 配置訪問白名單列表 ignoreUrls,白名單請求無需認證和鑒權;
- 配置本地方式獲取公鑰或者遠程獲取公鑰,公鑰驗證JWT的簽名,其中本地公鑰方式【有來項目】2.0.0版本新增;
- 配置未授權、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: '//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有詳細的描述 //github.com/hxrui/youlai-mall/issues/27
在這裡補充下其詳細生成過程
首先訪問 //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
許可權表的設計:
- 許可權表中的menu_id欄位標識該許可權屬於某個菜單模組,僅方便模組管理,無強關聯;
- 許可權標識分為介面許可權標識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許可權設計、管理平台的操作、許可權規則快取載入等都是為了這一步準備的,就是網關統一鑒權
當請求到網關時,如果有配置許可權攔截規則但未配置白名單的請求需要走鑒權的邏輯,下面通過程式碼調試來看下鑒權過程:
-
進入ResourceServerManager#check方法,網關鑒權開始,這裡拿
系統管理員
(ADMIN)訪問部門列表
介面舉例 -
根據請求方法和請求路徑拼接自定義的
restfulPath = GET:/youlai-admin/api/v1/depts
- 從Redis快取讀取許可權規則,可以看到許可權規則列表中有匹配
部門列表
介面的規則
- 從許可權規則中獲取有
部門列表
介面許可權的角色,可以看到有許可權的角色集合併沒有ADMIN
- 最後一步,拿當前用戶JWT攜帶的角色和擁有許可權的角色進行匹配,只要有一個匹配,就說明用戶擁有訪問許可權則放行,但上面的結果可想而知,
系統管理員
並沒有部門列表
介面的訪問許可權,則鑒權不通過被攔截
-
按鈕許可權
-
按鈕許可權實現原理
按鈕許可權控制的核心是Vue自定義指令,Vue除了內置指令有v-model 、v-if和v-show等,同樣也支援註冊自定義指令作用在元素上。
項目中使用
Vue.directive
註冊自定義指令v-has-permission
來判斷當前登錄用戶是否擁有按鈕許可權。看下圖就明白如何應用的: -
Vue自定義指令
如何自定義Vue指令並註冊成全局指令呢?其實
vue-element-admin
已自定義過很多的指令,僅需跟著照葫蘆畫瓢就行。- 在
src/directive/permission
路徑添加hasPermission.js
文件,編寫按鈕許可權控制程式碼邏輯
-
註冊
v-has-permission
全局指令,在main.js註冊成全局指 -
按鈕元素使用自定義指令
-
最後提一下,用戶是在登錄成功的時候獲取用戶資訊時拿到的按鈕許可權標識集合
- 在
五. 常見問題
收集一些項目的issue和被常見的問題。
-
啟動網關GatewayApplication報錯,Error:Kotlin: Module was compiled with an incompatible version of Kotlin.
IDEA禁用Kotlin插件
-
Mybatis參數和請求參數註解報錯
IDEA版本升級
-
token無效或已過期
- 進入//jwt.io/ 解析JWT查看是否過期
- 是否更換過密鑰庫jwt.jks,如果更換網關本地需同步更新公鑰內容public.key,執行mvn clean再啟動項目
- 源碼調試分析,JWT解析源碼坐標:NimbusReactiveJwtDecoder#decode;JWT過期校驗源碼坐標:JwtTimestampValidator#validate
-
OAuth2認證授權報錯,401 Unauthorized
客戶端資訊錯誤,新版本Spring Security OAuth2不支援客戶端資訊(client_id/client_secret)放入請求路徑,Base64加密後放在請求頭
-
認證中心Security已配置放行,還是進入不到/oauth/token介面
這個問題和上面的問題都可以在過濾器BasicAuthenticationFilter#doFilterInternal方法添加斷點調試分析
-
**Cannot load keys from store: class path resource [xxx.jks] **
- 檢查獲取KeyPair密鑰對的時候輸入的密鑰庫密碼是否正確
- 更換密鑰庫jwt.jks的同時網關需同步更新公鑰內容public.key,執行mvn clean再啟動項目
-
密碼或用戶名錯誤
源碼調試分析,密碼判斷源碼坐標:DaoAuthenticationProvider#additionalAuthenticationChecks
-
前端工程npm install報錯
- 本地是否安裝git
- 請確認有個好的網路環境,需從GitHub下載依賴
-
項目中使的用自動程式碼生成工具
MybatisX,Mybatis-Plus官方推薦的IDEA插件,優勢在於零配置實現MyBatis-Plus的程式碼生成,也支援Lombok,如果項目使用Mybatis-Plus,比較推薦
-
Maven依賴包缺失
-
配置阿里雲遠程倉庫,settings.xml找到
標籤替換為以下內容 <mirror> <id>alimaven</id> <name>aliyun maven</name> <url>//maven.aliyun.com/nexus/content/groups/public/</url> <mirrorOf>central</mirrorOf> </mirror>
-
刪除本地倉庫重新下載依賴至本地倉庫
-
OAuth2的認證授權介面請求頭Basic是怎麼得到
六. 寫在最後
本篇內容主要涉及OAuth2認證授權模式的原理以及應用,嚴格遵守微服務單一職責的設計原則,將資源伺服器從認證伺服器拆分出來,讓認證伺服器(認證中心)統一負責認證授權,資源伺服器(網關)統一處理鑒權,做到功能上的高度解耦。基於RBAC許可權模型設計一套適配微服務+前後端分離開發模式的許可權框架,在網關統一鑒權的設計基礎上實現了對RESTful規範介面的細粒度鑒權;藉助vue.directive
自定義指令實現頁面的按鈕許可權控制。總之,【有來】不僅僅是表面上的全棧商城項目,也是一套集成當下主流開發模式、主流技術棧的完整的微服務腳手架項目,沒有過度的自定義封裝邏輯,容易上手學習和方便二次擴展。最後希望各位道友多多關注開源項目的進展,一起加油,如果項目中遇到問題或者有什麼建議,歡迎聯繫我們。因為微信交流群超過200人了,只能通過邀請進入群聊,添加我的微信(haoxianrui)後我拉您進群,相互交流學習,備註「有來」即可。