本篇實戰案例基於 youlai-mall 項目。項目使用的是當前主流和最新版本的技術和解決方案,本身不會太多華麗的言辭去描述,只但願能勾起你們對編程的一點喜歡。因此有興趣的朋友能夠進入 github | 碼雲瞭解下項目明細 ,有興趣也能夠一塊兒研發。html
youlai-mall 經過整合 Spirng Cloud Gateway、Spring Security OAuth二、JWT 實現微服務的統一認證受權。其中Spring Cloud Gateway做爲OAuth2客戶端,其餘微服務提供資源服務給網關,交由網關來作統一鑑權,因此這裏網關一樣也做爲資源服務器。前端
舒適提示:微服務認證受權在整個系列算是比較有難度的,本篇同時從理論和實戰兩個角度出發,因此篇幅有些長,還須要往期文章搭建的環境基礎,但願你們能夠耐心的研究下。java
往期系列文章git
OAuth 2.0 是目前最流行的受權機制,用來受權第三方應用,獲取用戶數據。
-- 【阮一峯】OAuth 2.0 的一個簡單解釋github
QQ登陸OAuth2.0:對於用戶相關的OpenAPI(例如獲取用戶信息,動態同步,照片,日誌,分享等),爲了保護用戶數據的安全和隱私,第三方網站訪問用戶數據前都須要顯式的向用戶徵求受權。 -- 【QQ登陸】OAuth2.0開發文檔web
從上面定義能夠理解OAuth2是一個受權協議,而且普遍流行的應用。redis
下面經過「有道雲筆記」經過「QQ受權登陸」的案例來分析QQ的OAuth2平臺的具體實現。算法
流程關聯OAuth2的角色關聯以下:spring
(1)第三方應用程序(Third-party Application):案例中的"有道雲筆記"客戶端。 (2)HTTP服務提供商(HTTP Service):QQ (3)資源全部者(Resource Owner):用戶 (4)用戶代理(User Agent): 好比瀏覽器,代替用戶去訪問這些資源。 (5)認證服務器(Authorization Server):服務提供商專門用來處理認證的服務器。案例中QQ提供的認證受權。 (6)資源服務器(Resource server):即服務提供商存放用戶生成的資源的服務器。它與認證服務器,能夠是同一臺服務器,也能夠是不一樣的服務器。 這裏指客戶端拿到access_token要去訪問資源對象的服務器,好比咱們在有道雲裏的筆記。
JWT(JSON Web Token)是令牌token的一個子集,首先在服務器端身份認證經過後生成一個字符串憑證並返回給客戶端,客戶端請求服務器端時攜帶該token字符串進行鑑權認證。sql
JWT是無狀態的。 除了包含簽名算法、憑據過時時間以外,還可擴展添加額外信息,好比用戶信息等,因此無需將JWT存儲在服務器端。相較於cookie/session機制中須要將用戶信息保存在服務器端的session裏節省了內存開銷,用戶量越多越明顯。
JWT的結構以下:
看不明白不要緊,我先把youlai-mall認證經過後生成的access token(標準的JWT格式)放到JWT官網進行解析成方便觀看的結構體。
JWT字符串由Header(頭部)、Payload(負載)、Signature(簽名)三部分組成。
Header: JSON對象,用來描述JWT的元數據,alg屬性表示簽名的算法,typ標識token的類型 Payload: JSON對象,用來存放實際須要傳遞的數據, 除了默認字段,還能夠在此自定義私有字段 Signature: 對Header、Payload這兩部分進行簽名,簽名須要私鑰,爲了防止數據被篡改
至於必定要給這兩者沾點親帶點故的話。能夠說OAuth2在認證成功生成的令牌access_token能夠由JWT實現。
認證服務器落地 youlai-mall 的youlai-auth認證中心模塊,完整代碼地址: github | 碼雲
<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>
/** * 認證服務配置 */ @Configuration @EnableAuthorizationServer @AllArgsConstructor public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { private DataSource dataSource; private AuthenticationManager authenticationManager; private UserDetailsServiceImpl userDetailsService; /** * 客戶端信息配置 */ @Override @SneakyThrows public void configure(ClientDetailsServiceConfigurer clients) { JdbcClientDetailsServiceImpl jdbcClientDetailsService = new JdbcClientDetailsServiceImpl(dataSource); jdbcClientDetailsService.setFindClientDetailsSql(AuthConstants.FIND_CLIENT_DETAILS_SQL); jdbcClientDetailsService.setSelectClientDetailsSql(AuthConstants.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); 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(false); } /** * 容許表單認證 */ @Override public void configure(AuthorizationServerSecurityConfigurer security) { security.allowFormAuthenticationForClients(); } /** * 使用非對稱加密算法對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("youlai.jks"), "123456".toCharArray()); KeyPair keyPair = factory.getKeyPair( "youlai", "123456".toCharArray()); return keyPair; } /** * JWT內容加強 */ @Bean public TokenEnhancer tokenEnhancer() { return (accessToken, authentication) -> { Map<String, Object> map = new HashMap<>(2); User user = (User) authentication.getUserAuthentication().getPrincipal(); map.put(AuthConstants.JWT_USER_ID_KEY, user.getId()); map.put(AuthConstants.JWT_CLIENT_ID_KEY, user.getClientId()); ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map); return accessToken; }; } }
AuthorizationServerConfig這個配置類是整個認證服務實現的核心。總結下來就是兩個關鍵點,客戶端信息配置和access_token生成配置。
配置OAuth2認證容許接入的客戶端的信息,由於接入OAuth2認證服務器首先人家得承認你這個客戶端吧,就好比上面案例中的QQ的OAuth2認證服務器承認「有道雲筆記」客戶端。
同理,咱們須要把客戶端信息配置在認證服務器上來表示認證服務器所承認的客戶端。通常可配置在認證服務器的內存中,可是這樣很不方便管理擴展。因此實際最好配置在數據庫中的,提供可視化界面對其進行管理,方便之後像PC端、APP端、小程序端等多端靈活接入。
Spring Security OAuth2官方提供的客戶端信息表oauth_client_details
CREATE TABLE `oauth_client_details` ( `client_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `resource_ids` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `client_secret` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `scope` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `authorized_grant_types` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `web_server_redirect_uri` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `authorities` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `access_token_validity` int(11) NULL DEFAULT NULL, `refresh_token_validity` int(11) NULL DEFAULT NULL, `additional_information` varchar(4096) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `autoapprove` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, PRIMARY KEY (`client_id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
添加一條客戶端信息
INSERT INTO `oauth_client_details` VALUES ('client', NULL, '123456', 'all', 'password,refresh_token', '', NULL, NULL, NULL, NULL, NULL);
項目使用JWT實現access_token,關於access_token生成步驟的配置以下:
1. 生成密鑰庫
使用JDK工具的keytool生成JKS密鑰庫(Java Key Store),並將youlai.jks放到resources目錄
keytool -genkey -alias youlai -keyalg RSA -keypass 123456 -keystore youlai.jks -storepass 123456
-genkey 生成密鑰 -alias 別名 -keyalg 密鑰算法 -keypass 密鑰口令 -keystore 生成密鑰庫的存儲路徑和名稱 -storepass 密鑰庫口令
2. JWT內容加強
JWT負載信息默認是固定的,若是想自定義添加一些額外信息,須要實現TokenEnhancer的enhance方法將附加信息添加到access_token中。
3. JWT簽名
JwtAccessTokenConverter是生成token的轉換器,能夠實現指定token的生成方式(JWT)和對JWT進行簽名。
簽名其實是生成一段標識(JWT的Signature部分)做爲接收方驗證信息是否被篡改的依據。原理部分請參考這篇的文章:RSA加密、解密、簽名、驗籤的原理及方法
其中對JWT簽名有對稱和非對稱兩種方式:
對稱方式:認證服務器和資源服務器使用同一個密鑰進行加簽和驗籤 ,默認算法HMAC
非對稱方式:認證服務器使用私鑰加簽,資源服務器使用公鑰驗籤,默認算法RSA
非對稱方式相較於對稱方式更爲安全,由於私鑰只有認證服務器知道。
項目中使用RSA非對稱簽名方式,具體實現步驟以下:
(1). 從密鑰庫獲取密鑰對(密鑰+私鑰) (2). 認證服務器私鑰對token簽名 (3). 提供公鑰獲取接口供資源服務器驗籤使用
公鑰獲取接口
/** * RSA公鑰開放接口 */ @RestController @AllArgsConstructor public class PublicKeyController { private KeyPair keyPair; @GetMapping("/rsa/publicKey") public Map<String, Object> getKey() { RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAKey key = new RSAKey.Builder(publicKey).build(); return new JWKSet(key).toJSONObject(); } }
@Configuration @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests().requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() .and() .authorizeRequests().antMatchers("/rsa/publicKey").permitAll().anyRequest().authenticated() .and() .csrf().disable(); } /** * 若是不配置SpringBoot會自動配置一個AuthenticationManager,覆蓋掉內存中的用戶 */ @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Bean public PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } }
安全配置主要是配置請求訪問權限、定義認證管理器、密碼加密配置。
資源服務器落地 youlai-mall 的youlai-gateway微服務網關模塊,完整代碼地址: github | 碼雲
上文有提到過網關這裏是擔任資源服務器的角色,由於網關是微服務資源訪問的統一入口,因此在這裏作資源訪問的統一鑑權是再合適不過。
<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>
spring: security: oauth2: resourceserver: jwt: # 獲取JWT驗籤公鑰請求路徑 jwk-set-uri: 'http://localhost:9999/youlai-auth/rsa/publicKey' redis: database: 0 host: localhost port: 6379 password: cloud: gateway: discovery: locator: enabled: true # 啓用服務發現 lower-case-service-id: true routes: - id: youlai-auth uri: lb://youlai-auth predicates: - Path=/youlai-auth/** filters: - StripPrefix=1 - id: youlai-admin uri: lb://youlai-admin predicates: - Path=/youlai-admin/** filters: - StripPrefix=1 # 配置白名單路徑 white-list: urls: - "/youlai-auth/oauth/token" - "/youlai-auth/rsa/publicKey"
鑑權管理器是做爲資源服務器驗證是否有權訪問資源的裁決者,核心部分的功能先已經過註釋形式進行說明,後面再對具體形式補充。
/** * 鑑權管理器 */ @Component @AllArgsConstructor @Slf4j public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> { private RedisTemplate redisTemplate; private WhiteListConfig whiteListConfig; @Override public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) { ServerHttpRequest request = authorizationContext.getExchange().getRequest(); String path = request.getURI().getPath(); PathMatcher pathMatcher = new AntPathMatcher(); // 1. 對應跨域的預檢請求直接放行 if (request.getMethod() == HttpMethod.OPTIONS) { return Mono.just(new AuthorizationDecision(true)); } // 2. token爲空拒絕訪問 String token = request.getHeaders().getFirst(AuthConstants.JWT_TOKEN_HEADER); if (StrUtil.isBlank(token)) { return Mono.just(new AuthorizationDecision(false)); } // 3.緩存取資源權限角色關係列表 Map<Object, Object> resourceRolesMap = redisTemplate.opsForHash().entries(AuthConstants.RESOURCE_ROLES_KEY); 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 -> { // 3. 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; } }
第一、2處只是作些基礎訪問判斷,不作過多的說明
第3處從Redis緩存獲取資源權限數據。首先咱們會關注兩個問題:
(1). 資源權限數據是什麼樣格式數據? (2). 數據何時初始化到緩存中?
如下就帶着這兩個問題來分析要完成第4步從緩存獲取資源權限數據須要提早作哪些工做吧。
(1). 資源權限數據格式
須要把url和role_ids的映射關係緩存到redis,大體意思的意思能夠理解擁有url訪問權限的角色ID有哪些。
(2). 初始化緩存時機
SpringBoot提供兩個接口CommandLineRunner和ApplicationRunner用於容器啓動後執行一些業務邏輯,好比數據初始化和預加載、MQ監聽啓動等。兩個接口執行時機無差,惟一區別在於接口的參數不一樣。有興趣的朋友能夠了解一下這兩位朋友,之後會常常再見的哈~
那麼這裏的業務邏輯是在容器初始化完成以後將從MySQL讀取到資源權限數據加載到Redis緩存中,正中下懷,來看下具體實現吧。
Redis緩存中的資源權限數據
至此從緩存數據能夠看到擁有資源url訪問權限的角色信息,從緩存獲取賦值給resourceRolesMap。
第5處根據請求路徑去匹配resourceRolesMap的資url(Ant Path匹配規則),獲得對應資源所需角色信息添加到authorities。
第6處就是判斷用戶是否有權訪問資源的最終一步了,只要用戶的角色中匹配到authorities中的任何一個,就說明該用戶擁有訪問權限,容許經過。
這裏作的工做是將鑑權管理器AuthorizationManager配置到資源服務器、請求白名單放行、無權訪問和無效token的自定義異常響應。配置類基本上都是約定俗成那一套,核心功能和注意的細節點經過註釋說明。
/** * 資源服務器配置 */ @AllArgsConstructor @Configuration // 註解須要使用@EnableWebFluxSecurity而非@EnableWebSecurity,由於SpringCloud Gateway基於WebFlux @EnableWebFluxSecurity public class ResourceServerConfig { private AuthorizationManager authorizationManager; private CustomServerAccessDeniedHandler customServerAccessDeniedHandler; private CustomServerAuthenticationEntryPoint customServerAuthenticationEntryPoint; private WhiteListConfig whiteListConfig; @Bean public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { http.oauth2ResourceServer().jwt() .jwtAuthenticationConverter(jwtAuthenticationConverter()); // 自定義處理JWT請求頭過時或簽名錯誤的結果 http.oauth2ResourceServer().authenticationEntryPoint(customServerAuthenticationEntryPoint); http.authorizeExchange() .pathMatchers(ArrayUtil.toArray(whiteListConfig.getUrls(),String.class)).permitAll() .anyExchange().access(authorizationManager) .and() .exceptionHandling() .accessDeniedHandler(customServerAccessDeniedHandler) // 處理未受權 .authenticationEntryPoint(customServerAuthenticationEntryPoint) //處理未認證 .and().csrf().disable(); return http.build(); } /** * @linkhttps://blog.csdn.net/qq_24230139/article/details/105091273 * ServerHttpSecurity沒有將jwt中authorities的負載部分當作Authentication * 須要把jwt的Claim中的authorities加入 * 方案:從新定義ReactiveAuthenticationManager權限管理器,默認轉換器JwtGrantedAuthoritiesConverter */ @Bean public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstants.AUTHORITY_PREFIX); jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstants.AUTHORITY_CLAIM_NAME); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter); return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter); } }
/** * 無權訪問自定義響應 */ @Component public class CustomServerAccessDeniedHandler implements ServerAccessDeniedHandler { @Override public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException e) { ServerHttpResponse response=exchange.getResponse(); response.setStatusCode(HttpStatus.OK); response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); response.getHeaders().set("Access-Control-Allow-Origin","*"); response.getHeaders().set("Cache-Control","no-cache"); String body= JSONUtil.toJsonStr(Result.custom(ResultCodeEnum.USER_ACCESS_UNAUTHORIZED)); DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(Charset.forName("UTF-8"))); return response.writeWith(Mono.just(buffer)); } }
/** * 無效token/token過時 自定義響應 */ @Component public class CustomServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { @Override public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException e) { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.OK); response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); response.getHeaders().set("Access-Control-Allow-Origin", "*"); response.getHeaders().set("Cache-Control", "no-cache"); String body = JSONUtil.toJsonStr(Result.custom(ResultCodeEnum.USER_ACCOUNT_UNAUTHENTICATED)); DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(Charset.forName("UTF-8"))); return response.writeWith(Mono.just(buffer)); } }
用戶 | 角色ID | 角色名稱 |
---|---|---|
admin | 2 | 系統管理員 |
資源名稱 | 資源路徑 | 要求角色權限 |
---|---|---|
系統管理 | /youlai-admin/** | [1] |
菜單管理 | /youlai-admin/menus/** | [1,2] |
用戶管理 | /youlai-admin/users/** | [1,2] |
部門管理 | /youlai-admin/depts/** | [1,2] |
字典管理 | /youlai-admin/dictionaries/** | [1] |
角色管理 | /youlai-admin/roles/** | [1] |
資源管理 | /youlai-admin/resources/** | [1] |
從模擬的數據能夠看到admin擁有系統管理員的角色,而系統管理員只有菜單管理、用戶管理、部門管理三個請求資源的訪問權限,無其餘資源的訪問權限。
啓動管理平臺前端工程 youlai-mall-admin-web 完整代碼地址: github | 碼雲
訪問除了菜單管理、用戶管理、部門管理這三個系統管理員擁有訪問權限的資源以外,頁面都會提示「訪問未受權」,直接的說明了網關服務器實現了請求鑑權的目的。
至此,Spring Cloud的統一認證受權就實現了。其實還有不少能夠擴展的點,文章中把客戶端信息存儲在數據庫中,那麼能夠添加一個管理界面來維護這些客戶端信息,這樣即可靈活配置客戶端接入認證平臺、認證有效期等等。同時也還有未完成的事項,咱們知道JWT是無狀態的,那用戶在登出、修改密碼、註銷的時候怎麼能把JWT置爲無效呢?由於不可能像cookie/session機制把用戶信息從服務器刪除。因此這些都是值得思考的東西,我會在下篇文章提供對應的解決方案。
今天博客園的園齡達到6年了,6年的時間本身依然沒有折騰啥出來,工做還有生活的壓力都挺大的,但也不想就這樣放棄了,因此。。。加油吧!!!
完整源碼地址