`

spring security oauth 2 客户端模式研究 sparklr2 tonr2

阅读更多

对spring security oauth2 的客户端模式的研究。

首要要下载spring security oauth2的源码,源码中有官方的例子,sparklr2 和tonr2。sparklr2模拟授权服务器和资源服务器,tonr2模拟客户端。

在tonr2的index.jsp中加了一行代码:

<li><a href="${base}trusted/message">trusted message</a></li>

 

 然后打开http://localhost/tonr2/ 就可以点击trusted message这个链接,然后就是触发了oauth2的客户端模式的例子。

1.通过mvc的映射先触发了tonr2的SparklrController类的trusted函数。

2.tonr2的WebMvcConfig中已经初始化了SparklrServiceImpl。

3.trusted函数调用SparklrServiceImpl的getTrustedMessage()函数。

4.getTrustedMessage函数中调用trustedClientRestTemplate.getForObject(URI.create(sparklrTrustedMessageURL), String.class);

5.sparklrTrustedMessageURL同样是通过WebMvcConfig中@Value("${sparklrTrustedMessageURL}") String sparklrTrustedMessageURL,来初始化的。

6.trustedClientRestTemplate同样是通过WebMvcConfig中 @Qualifier("trustedClientRestTemplate") RestOperations trustedClientRestTemplate,来初始化的。

7.trustedClientRestTemplate是通过下面代码初始化的,实际上就是个OAuth2RestTemplate对象。

 

public OAuth2RestTemplate trustedClientRestTemplate() {
return new OAuth2RestTemplate(trusted(), new DefaultOAuth2ClientContext());
}
 8.trustedClientRestTemplate.getForObject 也就是OAuth2RestTemplate.getForObject 这个方法写在他的父类里。

 

	public <T> T getForObject(String url, Class<T> responseType, Object... urlVariables) throws RestClientException {
		RequestCallback requestCallback = acceptHeaderRequestCallback(responseType);
		HttpMessageConverterExtractor<T> responseExtractor =
				new HttpMessageConverterExtractor<T>(responseType, getMessageConverters(), logger);
		return execute(url, HttpMethod.GET, requestCallback, responseExtractor, urlVariables);
	}
 9.getForObject 里调用了acceptHeaderRequestCallback,acceptHeaderRequestCallback里调用了AcceptHeaderRequestCallback.
10.又调用了execute方法。execute方法有调用了doExecute方法。doExecute方法ClientHttpRequest request = createRequest(url, method);这句话调用了createRequest方法,这个方法是RestTemplate父类InterceptingHttpAccessor的父类HttpAccessor定义的,但是这个方法被RestTemplate的子类OAuth2RestTemplate重写了。代码如下:
@Override
	protected ClientHttpRequest createRequest(URI uri, HttpMethod method) throws IOException {

		OAuth2AccessToken accessToken = getAccessToken();

		AuthenticationScheme authenticationScheme = resource.getAuthenticationScheme();
		if (AuthenticationScheme.query.equals(authenticationScheme)
				|| AuthenticationScheme.form.equals(authenticationScheme)) {
			uri = appendQueryParameter(uri, accessToken);
		}

		ClientHttpRequest req = super.createRequest(uri, method);

		if (AuthenticationScheme.header.equals(authenticationScheme)) {
			authenticator.authenticate(resource, getOAuth2ClientContext(), req);
		}
		return req;

	}
 
11.其中getAccessToken方法中调用了acquireAccessToken方法。acquireAccessToken方法又调用了accessTokenProvider.obtainAccessToken。accessTokenProvider 是由AccessTokenProviderChain来实现的。
public OAuth2AccessToken obtainAccessToken(OAuth2ProtectedResourceDetails resource, AccessTokenRequest request)
			throws UserRedirectRequiredException, AccessDeniedException {

		OAuth2AccessToken accessToken = null;
		OAuth2AccessToken existingToken = null;
		Authentication auth = SecurityContextHolder.getContext().getAuthentication();

		if (auth instanceof AnonymousAuthenticationToken) {
			if (!resource.isClientOnly()) {
				throw new InsufficientAuthenticationException(
						"Authentication is required to obtain an access token (anonymous not allowed)");
			}
		}

		if (resource.isClientOnly() || (auth != null && auth.isAuthenticated())) {
			existingToken = request.getExistingToken();
			if (existingToken == null && clientTokenServices != null) {
				existingToken = clientTokenServices.getAccessToken(resource, auth);
			}

			if (existingToken != null) {
				if (existingToken.isExpired()) {
					if (clientTokenServices != null) {
						clientTokenServices.removeAccessToken(resource, auth);
					}
					OAuth2RefreshToken refreshToken = existingToken.getRefreshToken();
					if (refreshToken != null) {
						accessToken = refreshAccessToken(resource, refreshToken, request);
					}
				}
				else {
					accessToken = existingToken;
				}
			}
		}
		// Give unauthenticated users a chance to get a token and be redirected

		if (accessToken == null) {
			// looks like we need to try to obtain a new token.
			accessToken = obtainNewAccessTokenInternal(resource, request);

			if (accessToken == null) {
				throw new IllegalStateException("An OAuth 2 access token must be obtained or an exception thrown.");
			}
		}

		if (clientTokenServices != null && (resource.isClientOnly() || auth != null && auth.isAuthenticated())) {
			clientTokenServices.saveAccessToken(resource, auth, accessToken);
		}

		return accessToken;
	}
 
12.这里面调用了obtainNewAccessTokenInternal,obtainNewAccessTokenInternal中调用了ClientCredentialsAccessTokenProvider 的 obtainAccessToken方法。
for (AccessTokenProvider tokenProvider : chain) {
			if (tokenProvider.supportsResource(details)) {
				return tokenProvider.obtainAccessToken(details, request);
			}
		}
 
13.ClientCredentialsAccessTokenProvider 的 obtainAccessToken方法中他父类的OAuth2AccessTokenSupport的retrieveToken方法。
return getRestTemplate().execute(getAccessTokenUri(resource, form), getHttpMethod(),
					getRequestCallback(resource, form, headers), extractor , form.toSingleValueMap());
 
上面这句代码执行了accesstoken的获取动作。ORZ终于tmd得到了accesstoken,累死个人。剩下的就是一步一步的返回accesstoken。
14.OAuth2RestTemplate中他还是调用了super.createRequest。在父类的createRequest里有调用了SimpleClientHttpRequestFactory的createRequest,返回来了一个ClientHttpRequest对象。
15.doExecute方法中ClientHttpRequest request = createRequest(url, method); response = request.execute(); 得到父类的一个http请求,然后发出http请求。这里面是要到资源服务器端获取资源。
事实上sparklr2 包含了授权服务器和资源服务器的功能。从上面的分析可以看出客户端tonr2访问了sparklr2两次,第一次是为了获得accesstoken。第二次是为了获得资源。sparklr2 第一次扮演的是认证服务器的角色。第二次扮演的是资源服务器的角色。
接下来就是授权服务器端的逻辑:sparklr2 。
先说相应第一次请求accesstoken的情况。当sparklr2收到来至于tonr2的请求是,这个请求先被sparklr2的过滤器拦截(这些过滤器是spring security 自带的)。一共有10多个拦截器,被几个符合条件的拦截器拦截,BasicAuthenticationFilter AnonymousAuthenticationFilter SessionManagementFilter FilterSecurityInterceptor 具体拦截情况可以看log。
 
tonr2 10:10:12.997 [DEBUG] ClientCredentialsAccessTokenProvider - Retrieving token from http://localhost:80/sparklr2/oauth/token
tonr2 10:10:13.027 [DEBUG] RestTemplate - Created POST request for "http://localhost:80/sparklr2/oauth/token"
tonr2 10:10:13.028 [DEBUG] ClientCredentialsAccessTokenProvider - Encoding and sending form: {grant_type=[client_credentials], scope=[trust]}
sparklr23 10:10:13.050 [DEBUG] AntPathRequestMatcher - Checking match of request : '/oauth/token'; against '/webjars/**'
sparklr23 10:10:13.050 [DEBUG] AntPathRequestMatcher - Checking match of request : '/oauth/token'; against '/images/**'
sparklr23 10:10:13.050 [DEBUG] AntPathRequestMatcher - Checking match of request : '/oauth/token'; against '/oauth/uncache_approvals'
sparklr23 10:10:13.050 [DEBUG] AntPathRequestMatcher - Checking match of request : '/oauth/token'; against '/oauth/cache_approvals'
sparklr23 10:10:13.050 [DEBUG] OrRequestMatcher - Trying to match using Ant [pattern='/oauth/token']
sparklr23 10:10:13.050 [DEBUG] AntPathRequestMatcher - Checking match of request : '/oauth/token'; against '/oauth/token'
sparklr23 10:10:13.050 [DEBUG] OrRequestMatcher - matched
sparklr23 10:10:13.052 [DEBUG] FilterChainProxy - /oauth/token at position 1 of 11 in additional filter chain; firing Filter: 'WebAsyncManagerIntegrationFilter'
sparklr23 10:10:13.058 [DEBUG] FilterChainProxy - /oauth/token at position 2 of 11 in additional filter chain; firing Filter: 'SecurityContextPersistenceFilter'
sparklr23 10:10:13.059 [DEBUG] FilterChainProxy - /oauth/token at position 3 of 11 in additional filter chain; firing Filter: 'HeaderWriterFilter'
sparklr23 10:10:13.059 [DEBUG] HstsHeaderWriter - Not injecting HSTS header since it did not match the requestMatcher org.springframework.security.web.header.writers.HstsHeaderWriter$SecureRequestMatcher@bb64568
sparklr23 10:10:13.059 [DEBUG] FilterChainProxy - /oauth/token at position 4 of 11 in additional filter chain; firing Filter: 'LogoutFilter'
sparklr23 10:10:13.059 [DEBUG] AntPathRequestMatcher - Checking match of request : '/oauth/token'; against '/logout'
sparklr23 10:10:13.059 [DEBUG] FilterChainProxy - /oauth/token at position 5 of 11 in additional filter chain; firing Filter: 'BasicAuthenticationFilter'
sparklr23 10:10:13.063 [DEBUG] BasicAuthenticationFilter - Basic Authentication Authorization header found for user 'my-client-with-registered-redirect'
sparklr23 10:10:13.064 [DEBUG] ProviderManager - Authentication attempt using org.springframework.security.authentication.dao.DaoAuthenticationProvider
sparklr23 10:10:13.083 [DEBUG] BasicAuthenticationFilter - Authentication success: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@f3bb37ef: Principal: org.springframework.security.core.userdetails.User@3c4746e1: Username: my-client-with-registered-redirect; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_CLIENT; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@957e: RemoteIpAddress: 127.0.0.1; SessionId: null; Granted Authorities: ROLE_CLIENT
sparklr23 10:10:13.083 [DEBUG] FilterChainProxy - /oauth/token at position 6 of 11 in additional filter chain; firing Filter: 'RequestCacheAwareFilter'
sparklr23 10:10:13.084 [DEBUG] FilterChainProxy - /oauth/token at position 7 of 11 in additional filter chain; firing Filter: 'SecurityContextHolderAwareRequestFilter'
sparklr23 10:10:13.086 [DEBUG] FilterChainProxy - /oauth/token at position 8 of 11 in additional filter chain; firing Filter: 'AnonymousAuthenticationFilter'
sparklr23 10:10:13.086 [DEBUG] AnonymousAuthenticationFilter - SecurityContextHolder not populated with anonymous token, as it already contained: 'org.springframework.security.authentication.UsernamePasswordAuthenticationToken@f3bb37ef: Principal: org.springframework.security.core.userdetails.User@3c4746e1: Username: my-client-with-registered-redirect; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_CLIENT; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@957e: RemoteIpAddress: 127.0.0.1; SessionId: null; Granted Authorities: ROLE_CLIENT'
sparklr23 10:10:13.086 [DEBUG] FilterChainProxy - /oauth/token at position 9 of 11 in additional filter chain; firing Filter: 'SessionManagementFilter'
sparklr23 10:10:13.086 [DEBUG] CompositeSessionAuthenticationStrategy - Delegating to org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy@135de411
sparklr23 10:10:13.086 [DEBUG] FilterChainProxy - /oauth/token at position 10 of 11 in additional filter chain; firing Filter: 'ExceptionTranslationFilter'
sparklr23 10:10:13.086 [DEBUG] FilterChainProxy - /oauth/token at position 11 of 11 in additional filter chain; firing Filter: 'FilterSecurityInterceptor'
sparklr23 10:10:13.087 [DEBUG] AntPathRequestMatcher - Checking match of request : '/oauth/token'; against '/oauth/token'
sparklr23 10:10:13.088 [DEBUG] FilterSecurityInterceptor - Secure object: FilterInvocation: URL: /oauth/token; Attributes: [fullyAuthenticated]
sparklr23 10:10:13.088 [DEBUG] FilterSecurityInterceptor - Previously Authenticated: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@f3bb37ef: Principal: org.springframework.security.core.userdetails.User@3c4746e1: Username: my-client-with-registered-redirect; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_CLIENT; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@957e: RemoteIpAddress: 127.0.0.1; SessionId: null; Granted Authorities: ROLE_CLIENT
sparklr23 10:10:13.096 [DEBUG] AffirmativeBased - Voter: org.springframework.security.web.access.expression.WebExpressionVoter@51e2ee04, returned: 1
sparklr23 10:10:13.096 [DEBUG] FilterSecurityInterceptor - Authorization successful
sparklr23 10:10:13.096 [DEBUG] FilterSecurityInterceptor - RunAsManager did not change Authentication object
sparklr23 10:10:13.097 [DEBUG] FilterChainProxy - /oauth/token reached end of additional filter chain; proceeding with original chain
sparklr23 10:10:13.108 [DEBUG] FrameworkEndpointHandlerMapping - Looking up handler method for path /oauth/token
sparklr23 10:10:13.111 [DEBUG] FrameworkEndpointHandlerMapping - Returning handler method [public org.springframework.http.ResponseEntity<org.springframework.security.oauth2.common.OAuth2AccessToken> org.springframework.security.oauth2.provider.endpoint.TokenEndpoint.getAccessToken(java.security.Principal,java.util.Map<java.lang.String, java.lang.String>)]
sparklr23 10:10:13.131 [DEBUG] ClientCredentialsTokenGranter - Getting access token for: my-client-with-registered-redirect
sparklr23 10:10:13.219 [DEBUG] ExceptionTranslationFilter - Chain processed normally
sparklr23 10:10:13.219 [DEBUG] SecurityContextPersistenceFilter - SecurityContextHolder now cleared, as request processing completed
tonr2 10:10:13.227 [DEBUG] RestTemplate - POST request for "http://localhost:80/sparklr2/oauth/token" resulted in 200 (OK)
tonr2 10:10:13.415 [DEBUG] HttpMessageConverterExtractor - Reading [interface org.springframework.security.oauth2.common.OAuth2AccessToken] as "application/json;charset=UTF-8" using [org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@4c825fa3]
 
 1.第一次请求被BasicAuthenticationFilter拦截,具体内容如下:
 String[] tokens = extractAndDecodeHeader(header, request);
 先将request的header中Authorization参数拿出来。Authorization = Basic xxxxxxxxxxxxxxxxxxxxxxx;格式得,xxxxxxxxxxx是经过base64编码的。上句代码主要是将xxxxxxxxxxx解码。解码出来的内容应该是my-client-with-registered-redirect:nnnnnnnnn;其中my-client-with-registered-redirect是客户端id,是在你客户端配置的时候你自己设置进去的,同样在认证端也要配置,否则系统将认证失败。nnnnnnnnn是token。
然后生成一个UsernamePasswordAuthenticationToken,用my-client-with-registered-redirect做用户名,nnnnnnnnn做密码。如果是第一次客户端发请求是nnnnnnnnn是空的。接下来由authenticationManager提供认证功能。authenticationManager是通过调用ProviderManager来实现认证的,ProviderManager提供了两个provider,一个是AnonymousAuthenticationProvider,一个是DaoAuthenticationProvider。AnonymousAuthenticationProvider不支持UsernamePasswordAuthenticationToken。所以用DaoAuthenticationProvider的authenticate来认证。DaoAuthenticationProvider是通过它的父类AbstractUserDetailsAuthenticationProvider的authenticate来认证的,父类的authenticate又调用一些子类也就是DaoAuthenticationProvider的方法,例如retrieveUser,retrieveUser调用了UserDetailsService的实现类ClientDetailsUserDetailsService.loadUserByUsername方法。这个类中loadUserByUsername有调用了ClientDetailsService的实现类InMemoryClientDetailsService.loadClientByClientId方法来最终验证当前用户是否合法。
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
            messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
                "Only UsernamePasswordAuthenticationToken is supported"));

        // Determine username
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();

        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);

        if (user == null) {
            cacheWasUsed = false;

            try {
                user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
            } catch (UsernameNotFoundException notFound) {
                logger.debug("User '" + username + "' not found");

                if (hideUserNotFoundExceptions) {
                    throw new BadCredentialsException(messages.getMessage(
                            "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
                } else {
                    throw notFound;
                }
            }

            Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
        }

        try {
            preAuthenticationChecks.check(user);
            additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
        } catch (AuthenticationException exception) {
            if (cacheWasUsed) {
                // There was a problem, so try again after checking
                // we're using latest data (i.e. not from the cache)
                cacheWasUsed = false;
                user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
                preAuthenticationChecks.check(user);
                additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
            } else {
                throw exception;
            }
        }

        postAuthenticationChecks.check(user);

        if (!cacheWasUsed) {
            this.userCache.putUserInCache(user);
        }

        Object principalToReturn = user;

        if (forcePrincipalAsString) {
            principalToReturn = user.getUsername();
        }

        return createSuccessAuthentication(principalToReturn, authentication, user);
    }
 
 ClientDetailsUserDetailsService这个实现赋值是通过AuthorizationServerSecurityConfigurer类中配置代码来实现的。
 
	public void init(HttpSecurity http) throws Exception {
		registerDefaultAuthenticationEntryPoint(http);
		if (passwordEncoder != null) {
			http.getSharedObject(AuthenticationManagerBuilder.class)
					.userDetailsService(new ClientDetailsUserDetailsService(clientDetailsService()))
					.passwordEncoder(passwordEncoder());
		}
		else {
			http.userDetailsService(new ClientDetailsUserDetailsService(clientDetailsService()));
		}
		http.securityContext().securityContextRepository(new NullSecurityContextRepository()).and().csrf().disable()
				.httpBasic().realmName(realm);
	}
 
 InMemoryClientDetailsService这个实现赋值是通过ClientDetailsServiceConfigurer类中的配置代码来实现的。OAuth2ServerConfig中AuthorizationServerConfiguration方法会设置相应的客户端的客户端名等信息。当客户端向授权服务器发请求请求时,请求信息会自动包括当前的客户端的客户端名。授权服务器收到后就会和 OAuth2ServerConfig中AuthorizationServerConfiguration方法也就是InMemoryClientDetailsService配置的客户端信息作对比。一致就通过认证,并返回token。不一致就认证失败。
	public InMemoryClientDetailsServiceBuilder inMemory() throws Exception {
		InMemoryClientDetailsServiceBuilder next = getBuilder().inMemory();
		setBuilder(next);
		return next;
	}
 
clients.inMemory().withClient("tonr")
			 			.resourceIds(SPARKLR_RESOURCE_ID)
			 			.authorizedGrantTypes("authorization_code", "implicit")
			 			.authorities("ROLE_CLIENT")
			 			.scopes("read", "write")
			 			.secret("secret")
			 		.and()
			 		.withClient("tonr-with-redirect")
			 			.resourceIds(SPARKLR_RESOURCE_ID)
			 			.authorizedGrantTypes("authorization_code", "implicit")
			 			.authorities("ROLE_CLIENT")
			 			.scopes("read", "write")
			 			.secret("secret")
			 			.redirectUris(tonrRedirectUri)
			 		.and()
		 		    .withClient("my-client-with-registered-redirect")
	 			        .resourceIds(SPARKLR_RESOURCE_ID)
	 			        .authorizedGrantTypes("authorization_code", "client_credentials")
	 			        .authorities("ROLE_CLIENT")
	 			        .scopes("read", "trust")
	 			        .redirectUris("http://anywhere?key=value")
 2.第一次请求,被FilterSecurityInterceptor拦截,具体内容如下:
FilterSecurityInterceptor.invoke事件下有主要逻辑。
当次请求第一被拦截时。fi.getRequest().getAttribute(FILTER_APPLIED) == null 要为它赋值, fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);。
然后执行AbstractSecurityInterceptor.beforeInvocation函数。这里面先执行Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);这个函数作用是根据当前的url获取,我们根据这个url定义的访问权限。这个数据在什么地方配置或是说在什么地方初始化,我们一会再说。然后通过authenticateIfRequired方法获得Authentication。authenticateIfRequired方法主要是通过SecurityContextHolder.getContext().getAuthentication();(SecurityContextHolder.getContext()是在上一个拦截器BasicAuthenticationFilter中赋值的 SecurityContextHolder.getContext().setAuthentication(authResult);)。获取authenticated后然后进行决定是否允许当前请求访问。 this.accessDecisionManager.decide(authenticated, object, attributes); 默认是用AffirmativeBased来实现的。HttpConfigurationBuilder.createFilterSecurityInterceptor有相应的逻辑,可以自己配置如果没有配置默认用AffirmativeBased。有几种实现方式(UnanimousBased.java 只要有一个Voter不能完全通过权限要求,就禁止访问。 AffirmativeBased.java只要有一个Voter可以通过权限要求,就可以访问。 ConsensusBased.java只要通过的Voter比禁止的Voter数目多就可以访问了)。HttpConfigurationBuilder.createFilterSecurityInterceptor还初始化了WebExpressionVoter等信息。AffirmativeBased调用了WebExpressionVoter的vote函数。vote函数先找到当前类也就是WebExpressionVoter能处理的类型:WebExpressionConfigAttribute。DefaultWebSecurityExpressionHandler用spring默认的spel处理器StandardEvaluationContext来解析SpEL语法。DefaultWebSecurityExpressionHandler.createEvaluationContext会建立一个评估的上下文,这个函数有连个参数,1是authentication,2FilterInvocation,主要是通过1认证结果和2的访问路径构建评估上下文。ExpressionUtils.evaluateAsBoolean通过weca.getAuthorizeExpression()比较ctx得出是否通过还是拒绝的结果。说的简单点weca.getAuthorizeExpression()是配置中要求当前路径需要什么样的权限,例如/token需要fullauthorcation的权限,ctx是当前用户拥有的权限。
然后return InterceptorStatusToken,然后FilterSecurityInterceptor的invoke方法的 InterceptorStatusToken token = super.beforeInvocation(fi);这句话就走完了。

 

 
 
 
分享到:
评论
1 楼 masuweng 2019-02-16  
写的太好了,

相关推荐

Global site tag (gtag.js) - Google Analytics