在上一篇《OAuth 2.0 授權碼請求》中我們已經可以獲取到access_token了,本節將使用客戶端來訪問遠端資源
配置資源伺服器
授權伺服器負責生成併發放訪問令牌(access_token),客戶端在訪問受保護的資源時會帶上訪問令牌,資源伺服器需要解析並驗證客戶端帶的這個訪問令牌。
如果你的資源伺服器同時也是一個授權伺服器(資源伺服器和授權伺服器在一起),那麼資源伺服器就不需要考慮令牌解析的事情了,否則這一步是不可或缺的。
To use the access token you need a Resource Server (which can be the same as the Authorization Server). Creating a Resource Server is easy, just add @EnableResourceServer and provide some configuration to allow the server to decode access tokens. If your application is also an Authorization Server it already knows how to decode tokens, so there is nothing else to do. If your app is a standalone service then you need to give it some more configuration.
同時,把它們放在一起的話還有一個問題需要注意,我們知道過濾器是順序執行的,因此需要確保那些通過訪問令牌來訪問的資源路徑不能被主過濾攔下了,需要單獨摘出來。
Note: if your Authorization Server is also a Resource Server then there is another security filter chain with lower priority controlling the API resources. Fo those requests to be protected by access tokens you need their paths not to be matched by the ones in the main user-facing filter chain, so be sure to include a request matcher that picks out only non-API resources in the WebSecurityConfigurer above.
關於Spring Security中過濾器的順序可以參見
https://docs.spring.io/spring-security/site/docs/5.0.6.RELEASE/reference/htmlsingle/#filter-ordering
這裡偷個懶將它們放在一起:
package com.cjs.example.config; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; import org.springframework.security.oauth2.provider.expression.OAuth2WebSecurityExpressionHandler; @Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) throws Exception { super.configure(resources); } /** * 用於配置對受保護的資源的訪問規則 * 預設情況下所有不在/oauth/**下的資源都是受保護的資源 * {@link OAuth2WebSecurityExpressionHandler} */ @Override public void configure(HttpSecurity http) throws Exception { http.requestMatchers().antMatchers("/haha/**") .and() .authorizeRequests() .anyRequest().authenticated(); } }
這裡配置很簡潔,很多都用了預設的設定(比如:resourceId,accessDeniedHandler,sessionManagement等等,具體可參見原始碼)
接下來,看看本例中我們被保護的資源,簡單的幾個資源(都以/haha開頭),只為測試:
package com.cjs.example.controller; import com.cjs.example.domain.UserInfo; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.security.Principal; @RestController @RequestMapping("/haha") public class MainController { @GetMapping("/sayHello") public String sayHello(String name) { return "Hello, " + name; } @PreAuthorize("hasAnyRole('ADMIN')") @RequestMapping("/sayHi") public String sayHi() { return "hahaha"; } @RequestMapping("/userInfo") public UserInfo userInfo(Principal principal) { UserInfo userInfo = new UserInfo(); userInfo.setName(principal.getName()); return userInfo; } }
授權伺服器配置
package com.cjs.example.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; 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.token.TokenStore; import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore; @Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private RedisConnectionFactory connectionFactory; @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { super.configure(security); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("my-client-1") .secret("$2a$10$0jyHr4rGRdQw.X9mrLkVROdQI8.qnWJ1Sl8ly.yzK0bp06aaAkL9W") .authorizedGrantTypes("authorization_code", "refresh_token") .scopes("read", "write", "execute") .redirectUris("http://localhost:8081/login/oauth2/code/callback"); // .redirectUris("http://www.baidu.com"); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.tokenStore(tokenStore()); } @Bean public TokenStore tokenStore() { return new RedisTokenStore(connectionFactory); } public static void main(String[] args) { System.out.println(new org.apache.tomcat.util.codec.binary.Base64().encodeAsString("my-client-1:12345678".getBytes())); System.out.println(java.util.Base64.getEncoder().encodeToString("my-client-1:12345678".getBytes())); } }
和之前相比,我們增加了TokenStore,將Token儲存到Redis中。否則預設放在記憶體中的話每次重啟的話token都丟了。下面是一個例子:
application.yml如下:
server: port: 8080 spring: redis: host: 127.0.0.1 port: 6379 logging: level: root: debug org.springframework.web: debug org.springframework.security: debug
WebSecurity配置
我們有了資源,有了授權,我們還缺少使用者。WebSecurity主要是配置我們們這個專案的一些安全配置,比如使用者、認證、授權等等。
package com.cjs.example.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("zhangsan") .password("$2a$10$qsJ/Oy1RmUxFA.YtDT8RJ.Y2kU3U4z0jvd35YmiMOAPpD.nZUIRMC") .roles("USER") .and() .withUser("lisi") .password("$2a$10$qsJ/Oy1RmUxFA.YtDT8RJ.Y2kU3U4z0jvd35YmiMOAPpD.nZUIRMC") .roles("USER", "ADMIN"); } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/css/**", "/js/**", "/plugins/**", "/favicon.ico"); } @Override protected void configure(HttpSecurity http) throws Exception { super.configure(http); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } public static void main(String[] args) { BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); System.out.println(bCryptPasswordEncoder.encode("123456")); System.out.println(bCryptPasswordEncoder.encode("12345678")); } }
這裡多說兩句,關於Endpoint和HttpSecurity
Endpoint
有很多端點我們是可以重寫的,比如:/login,/oauth/token等等
HttpSecurity
很多初學者可能會不知道怎麼配置HttpSecurity,這個時候其實最好的方法就是看程式碼或者API文件
下面一起看一下常見的幾個配置
我們先來看一下,當我們繼承WebSecurityConfigurerAdapter之後它的預設的HttpSecurity是怎麼配置的:
// @formatter:off protected void configure(HttpSecurity http) throws Exception { logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity)."); http .authorizeRequests() .anyRequest().authenticated() .and() .formLogin().and() .httpBasic(); } // @formatter:on
可以看到,所有的請求都需要授權,並且指定登入的uri是/login,同時支援Basic認證。
requestMatchers()
這個方法是用於限定只有特定的HttpServletRequest例項才會導致該HttpSecurity被呼叫,當然是通過請求uri進行限定的了。它後面可以接多個匹配規則。例如:
@Configuration @EnableWebSecurity public class RequestMatchersSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .requestMatchers() .antMatchers("/api/**") .antMatchers("/oauth/**") .and() .authorizeRequests() .antMatchers("/**").hasRole("USER") .and() .httpBasic(); } /* 與上面那段等價 @Override protected void configure(HttpSecurity http) throws Exception { http .requestMatchers() .antMatchers("/api/**") .and() .requestMatchers() .antMatchers("/oauth/**") .and() .authorizeRequests() .antMatchers("/**").hasRole("USER") .and() .httpBasic(); } */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth .inMemoryAuthentication() .withUser("user").password("password").roles("USER"); } }
formLogin()
該方法是用於配置登入相關的設定的。例如:
@Configuration @EnableWebSecurity public class FormLoginSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().antMatchers("/**").hasRole("USER").and().formLogin() .usernameParameter("username") // default is username .passwordParameter("password") // default is password .loginPage("/authentication/login") // default is /login with an HTTP get .failureUrl("/authentication/login?failed") // default is /login?error .loginProcessingUrl("/authentication/login/process"); // default is /login } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication().withUser("user").password("password").roles("USER"); } }
當我們沒有配置登入的時候,會用預設的登入,有預設的登入頁面,還有好多預設的登入配置。具體可參見 FormLoginConfigurer.loginPage(String)方法
authorizeRequests()
該方法允許基於HttpServletRequest進行訪問限制,比如角色、許可權。例如:
@Configuration @EnableWebSecurity public class AuthorizeUrlsSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().antMatchers("/admin/**").hasRole("ADMIN") .antMatchers("/**").hasRole("USER").and().formLogin(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication().withUser("user").password("password").roles("USER") .and().withUser("admin").password("password").roles("ADMIN", "USER"); } }
anyRequest()表示匹配任意請求
authenticated()表示只有認證通過的使用者才可以訪問
更多可以參見API文件:https://docs.spring.io/spring-security/site/docs/5.0.6.RELEASE/api/
用Postman訪問資源
獲取授權碼
在瀏覽器中輸入http://localhost:8080/oauth/authorize?response_type=code&client_id=my-client-1&redirect_uri=http://www.baidu.com&scope=read
然後跳到登入頁面,輸入使用者名稱和密碼登入,然後從重定向url中拿到code
換取訪問令牌
訪問資源
http://localhost:8080/haha/sayHi?access_token=9f908b8f-06d6-4987-b105-665ca5a4522a { "error": "access_denied", "error_description": "不允許訪問" } 這裡不允許訪問是因為我用zhangsan登入的,他不在ADMIN角色中 http://localhost:8080/haha/userInfo?access_token=9f908b8f-06d6-4987-b105-665ca5a4522a { "name": "zhangsan" } http://localhost:8080/haha/sayHello?name=jack&access_token=9f908b8f-06d6-4987-b105-665ca5a4522a Hello, jack
通過客戶端程式訪問資源
訪問GitHub
參照SpringBoot官網的示例
https://github.com/spring-projects/spring-boot/tree/master/spring-boot-samples/spring-boot-sample-oauth2-client
https://github.com/spring-projects
https://github.com/spring-projects/spring-boot
可以將程式碼拷下來 https://github.com/spring-projects/spring-boot.git
第一步 將我們的客戶端註冊到GitHub上去
GitHub的OAuth文件在這裡 https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/
第二步 瀏覽器輸入http://localhost:8080/後出現如下介面
第三步 點選任意一個連線,跳GitHub登入
授權以後就可以重定向到我們的localhost:8080/了
主要配置如下:
APP-CLIENT-ID: 7e304109d91ed8e9bf72
APP-CLIENT-SECRET: 003daa47fa0f350d181c8741d8bac6833aef568a
spring:
security:
oauth2:
client:
registration:
github-client-1:
client-id: ${APP-CLIENT-ID}
client-secret: ${APP-CLIENT-SECRET}
client-name: Github user
provider: github
scope: user
redirect-uri-template: http://cjshuashengke.6655.la:11664/login/oauth2/code/github
github-client-2:
client-id: ${APP-CLIENT-ID}
client-secret: ${APP-CLIENT-SECRET}
client-name: Github email
provider: github
scope: user:email
redirect-uri-template: http://cjshuashengke.6655.la:11664/login/oauth2/code/github
pom.xml如下
<?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"> <modelVersion>4.0.0</modelVersion> <groupId>sample.oauth2.client</groupId> <artifactId>spring-boot-sample-oauth2-client</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>jar</packaging> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.0.3.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <java.version>1.8</java.version> </properties> <dependencies> <!-- Compile --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-client</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-jose</artifactId> </dependency> <!-- Test --> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
啟動類
package sample.oauth2.client; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class SampleOAuth2ClientApplication { public static void main(String[] args) { SpringApplication.run(SampleOAuth2ClientApplication.class); } }
ExampleController
package sample.oauth2.client; import java.security.Principal; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class ExampleController { @RequestMapping("/") public String email(Principal principal) { return "Hello " + principal.getName(); } }
訪問自定義的Provider
在這裡我遇到一些問題,將provider換成自己的localhost:8080就總是報錯,不知道什麼原因。
想想還是寫出來吧,希望有大神看到幫我指點迷津。。。
首先需要明確一點:Spring Security OAuth2提供了一套客戶端實現,Spring Boot也有它自己的方式。
這裡我是按照Spring Boot的那一套來的
Spring Boot最大的優點莫過於自動配置了
只要在你的classpath下有spring-security-oauth2-client,那麼將會自動配置OAuth2 Client。
配置的屬性用的是OAuth2ClientProperties
例如:
預設情況下,Spring Security的OAuth2LoginAuthenticationFilter只處理URL匹配/login/oauth2/code/*的請求。
如果你想自定義redirect-uri-template可以在WebSecurityConfigurerAdapter中配置。例如:
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login()
.redirectionEndpoint()
.baseUri("/custom-callback");
}
}
其實,不僅僅可以自定義重定向端點,其它的比如授權端點也是可以自定義的,可參見原始碼
下面重點說下我的配置
pom.xml
<?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"> <modelVersion>4.0.0</modelVersion> <groupId>com.cjs.example</groupId> <artifactId>cjs-oauth2-code-client</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>jar</packaging> <name>cjs-oauth2-code-client</name> <description></description> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.0.2.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-client</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-jose</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.3.3.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
application.yml
server: port: 8081 spring: security: oauth2: client: registration: my-client-1: client-id: my-client-1 client-secret: 12345678 client-name: ABC provider: my-oauth-provider scope: read redirect-uri-template: http://localhost:8081/login/oauth2/code/callback client-authentication-method: basic authorization-grant-type: authorization_code provider: my-oauth-provider: authorization-uri: http://localhost:8080/oauth/authorize token-uri: http://localhost:8080/oauth/token logging: level: root: debug
分別啟動兩個專案
瀏覽器輸入http://localhost:8080/test
點選連結ABC
輸入zhangsan/123456,結果報錯了
檢視控制檯錯誤資訊
org.springframework.security.oauth2.core.OAuth2AuthenticationException: [authorization_request_not_found] at org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter.attemptAuthentication(OAuth2LoginAuthenticationFilter.java:146) at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:212) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334) [spring-security-web-5.0.5.RELEASE.jar:5.0.5.RELEASE] at org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter.doFilterInternal(OAuth2AuthorizationRequestRedirectFilter.java:128) at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-5.0.6.RELEASE.jar:5.0.6.RELEASE]
打端點,發現重定向過來的HttpServletRequest中session=null
正常情況下session不應該是null,而且屬性中還應該有一個key為org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST的屬性的
後來看根據Spring Security那一套說用@EnableOAuth2Client
利用OAuth2RestTemplate來訪問資源,具體參見《Spring Security OAuth 2.0》
但是我發現加了這些問題依舊,根本問題是服務端帶著code重定向到客戶端的時候就失敗了
在客戶端收到的重定向請求中session為null
不知道大家有沒有遇到這種問題,求路過的大神們支招。。。
下面貼出客戶端完整程式碼
package com.cjs.example.controller; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.oauth2.client.OAuth2RestTemplate; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import java.security.Principal; @Controller public class MainController { @Autowired private OAuth2RestTemplate oAuth2RestTemplate; @GetMapping("/test") public String test(Principal principal) { return "Hello, " + principal.getName(); } @GetMapping("/hello") public String hello() { return oAuth2RestTemplate.getForObject("http://localhost:8080/sayHi", String.class); } }
package com.cjs.example; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.security.oauth2.client.OAuth2ClientContext; import org.springframework.security.oauth2.client.OAuth2RestTemplate; import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeResourceDetails; import org.springframework.context.annotation.Bean; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableOAuth2Client; @EnableOAuth2Client @SpringBootApplication public class CjsOauth2CodeClientApplication { public static void main(String[] args) { SpringApplication.run(CjsOauth2CodeClientApplication.class, args); } @Autowired private OAuth2ClientContext oauth2ClientContext; @Bean public OAuth2RestTemplate restTemplate() { return new OAuth2RestTemplate(new AuthorizationCodeResourceDetails(), oauth2ClientContext); } }
工程截圖
程式碼上傳至 https://github.com/chengjiansheng/cjs-oauth2-example.git
參考
https://docs.spring.io/spring-security/site/docs/5.0.6.RELEASE/api/
https://github.com/spring-projects/spring-boot/tree/master/spring-boot-samples
https://segmentfault.com/a/1190000012309216
https://www.jianshu.com/p/bf5dd33aea6d?utm_source=oschina-app
https://www.aliyun.com/jiaocheng/800606.html
https://blog.csdn.net/peter1220/article/details/52413250
https://blog.csdn.net/weixin_42033269/article/details/80086422