Camunda工作流平台与Keycloak的集成

Camunda是一个流行的工作流平台,其自带了基本的用户管理功能。Keycloak是业界主流的一个提供OAUTH等协议标准的一个用户验证与授权的平台。这里介绍如何把Camunda与Keycloak相集成,以实现通过Keycloak来统一管理用户的鉴权与授权,用户通过从Keycloak获取Token来调用Camunda的API。这篇文章也是参考了Github的这个仓库来写的,并经过实际测试有效:GitHub - camunda-community-hub/camunda-platform-7-keycloak: Camunda Keycloak Identity Provider Plugin

Keycloak的设置

这里采用Keycloak+PG数据库的方式来启动。PG数据库是和Camunda共用的。通过以下Docker命令来启动:

docker run --name pg12 -v /home/roy/data/pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:12

这里采用Docker的方式来启动Keycloak,命令如下:

docker run -p 9090:8080 --name keycloak --link pg12 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=XXXXX quay.io/keycloak/keycloak:18.0.0 start-dev --db-url jdbc:postgresql://pg12:5432/keycloak --db-username postgres --db-password postgres --db=postgres

运行之后就可以访问localhost:9090来进入到Keycloak的设置了。

在Keycloak里面新建一个名为camunda的realm,然后在clients里面新建一个名为camunda-identity-service的client,配置如下:

 为了能使用refresh token,需要在OpenID Connect Compatibility Modes里面开启选项Use Refresh Tokens For Client Credentials Grant, 如下图:

 在Service Account Roles里面添加query-groups, query-users, view-users这3个Role,如下图:

 在这个camunda的realm里面新建一个用户组camunda-admin,如下图:

Keycloak的设置完成之后,我们就可以用Springboot来集成Camunda了

集成Springboot与Camunda

首先我们需要新建一个Springboot的应用,集成Camunda。具体可以参照我之前的文章:Springboot集成Camunda流程引擎_gzroy的博客-CSDN博客 

添加Keycloak插件

在pom.xml文件里面增加以下依赖:

<dependency>
     <groupId>org.camunda.bpm.extension</groupId>
     <artifactId>camunda-platform-7-keycloak</artifactId>
     <version>${camunda.spring-boot.version}</version>
</dependency>

在src目录新建一个plugin的目录,里面新建一个KeycloakIdentityProvider.java,代码如下:

package com.roy.camunda.plugin;

import org.camunda.bpm.extension.keycloak.plugin.KeycloakIdentityProviderPlugin;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;


@Component
@ConfigurationProperties(prefix="plugin.identity.keycloak")
public class KeycloakIdentityProvider extends KeycloakIdentityProviderPlugin {

}

 在application.yml配置文件中添加以下配置:

camunda.bpm:
  authorization:
    enabled: true

plugin.identity.keycloak:
  keycloakIssuerUrl: http://localhost:9090/realms/camunda
  keycloakAdminUrl: http://localhost:9090/admin/realms/camunda
  clientId: camunda-identity-service
  clientSecret: XXXXXXXXXXXXXXXX
  useEmailAsCamundaUserId: false
  useUsernameAsCamundaUserId: true
  useGroupPathAsCamundaGroupId: true
  administratorGroupName: camunda-admin
  disableSSLCertificateValidation: true

设置Springboot Security

需要在pom.xml里面添加以下依赖:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

需要新增一个KeycloakAuthenticationProvider来连接Spring security和Camunda。在src里面新建一个目录sso,里面新建一个KeycloakAuthenticationProvider.java文件,内容如下:

package com.roy.camunda.sso;

import java.util.ArrayList;
import java.util.List;

import javax.servlet.http.HttpServletRequest;

import org.camunda.bpm.engine.ProcessEngine;
import org.camunda.bpm.engine.rest.security.auth.AuthenticationResult;
import org.camunda.bpm.engine.rest.security.auth.impl.ContainerBasedAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.util.StringUtils;

/**
 * OAuth2 Authentication Provider for usage with Keycloak and KeycloakIdentityProviderPlugin. 
 */
public class KeycloakAuthenticationProvider extends ContainerBasedAuthenticationProvider {

    @Override
    public AuthenticationResult extractAuthenticatedUser(HttpServletRequest request, ProcessEngine engine) {

        // Extract user-name-attribute of the OAuth2 token
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		if (!(authentication instanceof OAuth2AuthenticationToken) || !(authentication.getPrincipal() instanceof OidcUser)) {
			return AuthenticationResult.unsuccessful();
		}
        String userId = ((OidcUser)authentication.getPrincipal()).getName();
        if (!StringUtils.hasLength(userId)) {
            return AuthenticationResult.unsuccessful();
        }

        // Authentication successful
        AuthenticationResult authenticationResult = new AuthenticationResult(userId, true);
        authenticationResult.setGroups(getUserGroups(userId, engine));

        return authenticationResult;
    }

    private List<String> getUserGroups(String userId, ProcessEngine engine){
        List<String> groupIds = new ArrayList<>();
        // query groups using KeycloakIdentityProvider plugin
        engine.getIdentityService().createGroupQuery().groupMember(userId).list()
        	.forEach( g -> groupIds.add(g.getId()));
        return groupIds;
    }

}

保护REST API

我们使用JWT来保护rest API的调用,如以下流程:

  1. 客户端在调用camunda rest API之前需要先从keycloak获取JWT
  2. 客户端在调用API的header里面设置JWT
  3. Camunda验证Token并从token中获取用户id和group id

在application.yml里面增加以下配置:

# Camunda Rest API
rest.security:
  enabled: true
  provider: keycloak
  required-audience: camunda-rest-api

为了能让keycloak在生成的JWT里面包括Camunda期望的audience claim,需要配置一个Client Scope,名称为camunda-rest-api,如下图:

 然后再mappers里面新增一个mapper,类型为Audience,然后配置需要的audience camunda-rest-api,如下图:

最后把这个client scope加到之前创建的client Camunda-Identity-Service中,如下图:

以上的配置可以使得经过Camunda-Identity-Service验证的用户能够访问Camunda的rest API

在src目录下新建一个目录rest,然后新建一个文件RestApiSecurityConfig.java,内容如下,其中configure和jwtDecoder这两个函数中被注释的部分是原代码,我测试了发现无法检验token里面的aud以及对用户的role进行校验,因此需要改造一下。另外还新增了一个函数对Keycloak的token中的Role进行转换,即增加ROLE_的前缀。这个设置可以确保只有用户具有admin的role,并且client的audience是camunda-rest-api的才有权限访问engine-rest/的API:

package com.roy.camunda.rest;

import javax.inject.Inject;

import org.camunda.bpm.engine.IdentityService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;

/**
 * Optional Security Configuration for Camunda REST Api.
 */
@Configuration
@EnableWebSecurity
@Order(SecurityProperties.BASIC_AUTH_ORDER - 20)
@ConditionalOnProperty(name = "rest.security.enabled", havingValue = "true", matchIfMissing = true)
public class RestApiSecurityConfig extends WebSecurityConfigurerAdapter {

	/** Configuration for REST Api security. */
	@Inject
	private RestApiSecurityConfigurationProperties configProps;
	
	/** Access to Camunda's Identity Service. */
	@Inject
	private IdentityService identityService;
	
	/** Access to Spring Security OAuth2 client service. */
	@Inject
	private OAuth2AuthorizedClientService clientService;

	@Inject
	private ApplicationContext applicationContext;
	
	/**
	 * {@inheritDoc}
	 */
	@Override
	public void configure(final HttpSecurity http) throws Exception {
		/*
		String jwkSetUri = applicationContext.getEnvironment().getRequiredProperty(
				"spring.security.oauth2.client.provider." + configProps.getProvider() + ".jwk-set-uri");

		http
    	.csrf().ignoringAntMatchers("/api/**", "/engine-rest/**")
    	.and()
        .antMatcher("/engine-rest/**")
	        .authorizeRequests()
	          .anyRequest().authenticated()
        .and()
        .oauth2ResourceServer()
        	.jwt().jwkSetUri(jwkSetUri)
		;
		*/
		http.authorizeRequests(authorizeRequests -> authorizeRequests
                .antMatchers("/engine-rest/**").hasRole("admin")
                .anyRequest().authenticated()).oauth2ResourceServer(
                oauth2ResourceServer -> oauth2ResourceServer.jwt(
                        jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
        );
	}

	private Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
        JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
        jwtConverter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
        return jwtConverter;
    }

	/**
	 * Create a JWT decoder with issuer and audience claim validation.
	 * @return the JWT decoder
	 */
	@Bean
	public JwtDecoder jwtDecoder() {
		/*
		String issuerUri = applicationContext.getEnvironment().getRequiredProperty(
				"spring.security.oauth2.client.provider." + configProps.getProvider() + ".issuer-uri");
		NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)
				JwtDecoders.fromOidcIssuerLocation(issuerUri);

		OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(configProps.getRequiredAudience());
		OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
		OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);

		jwtDecoder.setJwtValidator(withAudience);
		*/
		String jwkSetUri = applicationContext.getEnvironment().getRequiredProperty(
				"spring.security.oauth2.client.provider." + configProps.getProvider() + ".jwk-set-uri");
		String issuerUri = applicationContext.getEnvironment().getRequiredProperty(
			"spring.security.oauth2.client.provider." + configProps.getProvider() + ".issuer-uri");
		OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(configProps.getRequiredAudience());
		OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
		OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
		NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
		jwtDecoder.setJwtValidator(withAudience);
		return jwtDecoder;
	}
	   
    /**
     * Registers the REST Api Keycloak Authentication Filter.
     * @return filter registration
     */
    @SuppressWarnings({ "unchecked", "rawtypes" })
	@Bean
    public FilterRegistrationBean keycloakAuthenticationFilter(){
        FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
        filterRegistration.setFilter(new KeycloakAuthenticationFilter(identityService, clientService));
        filterRegistration.setOrder(102); // make sure the filter is registered after the Spring Security Filter Chain
        filterRegistration.addUrlPatterns("/engine-rest/*");
        return filterRegistration;
    }
   
}

新增一个文件KeycloakRealmRoleConverter.java,进行Keycloak role的转换,内容如下:

package com.roy.camunda.rest;

import java.util.Collection;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.List;

import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;

public class KeycloakRealmRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
    @Override
    public Collection<GrantedAuthority> convert(Jwt jwt) {
        final Map<String, Object> realmAccess = (Map<String, Object>) jwt.getClaims().get("realm_access");
        return ((List<String>)realmAccess.get("roles")).stream()
                .map(roleName -> "ROLE_" + roleName) // prefix to map to a Spring Security "role"
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }
}

新建一个文件RestApiSecurityConfigurationProperties.java, 内容如下:

package com.roy.camunda.rest;

import javax.validation.constraints.NotEmpty;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;

/**
 * Complete Security Configuration Properties for Camunda REST Api.
 */
@Component
@ConfigurationProperties(prefix = "rest.security")
@Validated
public class RestApiSecurityConfigurationProperties {

	/** 
	 * rest.security.enabled:
	 * 
	 * Rest Security is enabled by default. Switch off by setting this flag to {@code false}.
	 */
	private Boolean enabled = true;

	/** 
	 * rest.security.provider:
	 * 
	 * The name of the spring.security.oauth2.client.provider to use
	 */
	@NotEmpty
	private String provider;

	/** 
	 * rest.security.required-audience:
	 * 
	 * Required Audience. 
	 */
	@NotEmpty
	private String requiredAudience;
	
	// ------------------------------------------------------------------------

	/**
	 * @return the requiredAudience
	 */
	public String getRequiredAudience() {
		return requiredAudience;
	}

	/**
	 * @param requiredAudience the requiredAudience to set
	 */
	public void setRequiredAudience(String requiredAudience) {
		this.requiredAudience = requiredAudience;
	}

	/**
	 * @return the enabled
	 */
	public Boolean getEnabled() {
		return enabled;
	}

	/**
	 * @param enabled the enabled to set
	 */
	public void setEnabled(Boolean enabled) {
		this.enabled = enabled;
	}

	/**
	 * @return the provider
	 */
	public String getProvider() {
		return provider;
	}

	/**
	 * @param provider the provider to set
	 */
	public void setProvider(String provider) {
		this.provider = provider;
	} 

}

新增一个文件AudienceValidator.java,校验JWT中是否包括所需的audience,内容如下:

package com.roy.camunda.rest;

import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;

/**
 * Token validator for audience claims.
 */
public class AudienceValidator implements OAuth2TokenValidator<Jwt> {

	/** The required audience. */
	private final String audience;

	/**
	 * Creates a new audience validator
	 * @param audience the required audience
	 */
	public AudienceValidator(String audience) {
		this.audience = audience;
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public OAuth2TokenValidatorResult validate(Jwt jwt) {
		if (jwt.getAudience().contains(audience)) {
			return OAuth2TokenValidatorResult.success();
		}
		return OAuth2TokenValidatorResult.failure(
			new OAuth2Error("invalid_token", "The required audience is missing", null));
	}
}

新增一个文件KeycloakAuthenticationFilter.java,其作用是注册一个过滤器到Spring security的过滤器链的末尾,把验证过的user id和group id发送给Camunda的IdentityService,内容如下:

package com.roy.camunda.rest;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;

import org.camunda.bpm.engine.IdentityService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.util.StringUtils;

/**
 * Keycloak Authentication Filter - used for REST API Security.
 */
public class KeycloakAuthenticationFilter implements Filter {

	/** This class' logger. */
	private static final Logger LOG = LoggerFactory.getLogger(KeycloakAuthenticationFilter.class);
	
	/** Access to Camunda's IdentityService. */
	private IdentityService identityService;
	
	/** Access to the OAuth2 client service. */
	OAuth2AuthorizedClientService clientService;
	
	/**
	 * Creates a new KeycloakAuthenticationFilter.
	 * @param identityService access to Camunda's IdentityService
	 */
	public KeycloakAuthenticationFilter(IdentityService identityService, OAuth2AuthorizedClientService clientService) {
		this.identityService = identityService;
		this.clientService = clientService;
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {

	    // Extract user-name-attribute of the JWT / OAuth2 token
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		String userId = null;
		if (authentication instanceof JwtAuthenticationToken) {
			userId = ((JwtAuthenticationToken)authentication).getName();
		} else if (authentication.getPrincipal() instanceof OidcUser) {
			userId = ((OidcUser)authentication.getPrincipal()).getName();
		} else {
			throw new ServletException("Invalid authentication request token");
		}
        if (StringUtils.isEmpty(userId)) {
        	throw new ServletException("Unable to extract user-name-attribute from token");
        }

        LOG.debug("Extracted userId from bearer token: {}", userId);

        try {
        	identityService.setAuthentication(userId, getUserGroups(userId));
        	chain.doFilter(request, response);
        } finally {
        	identityService.clearAuthentication();
        }
	}

    /**
     * Queries the groups of a given user.
     * @param userId the user's ID
     * @return list of groups the user belongs to
     */
    private List<String> getUserGroups(String userId){
        List<String> groupIds = new ArrayList<>();
        // query groups using KeycloakIdentityProvider plugin
        identityService.createGroupQuery().groupMember(userId).list()
        	.forEach( g -> groupIds.add(g.getId()));
        return groupIds;
    }
}

测试

现在我们可以在这个Springboot项目中输入mvn clean install,以及mvn spring-boot:run来运行Camunda引擎了。

在Keycloak中创建一个新的用户并加入到之前创建的camunda-admin组里面,新建一个client,例如名称为test-client,在Setting里面的Implicit Flow Enabled设置为ON,client scopes里面把camunda-rest-api赋予到assigned default client scopes。

在Keycloak里面创建一个名为admin的role,并把这个role赋予给camunda-admin。

之后我们在浏览器访问以下URL:http://localhost:9090/realms/camunda/protocol/openid-connect/auth?response_type=token&client_id=test-client&redirect_uri=http://localhost:9090/login

这时会去到Keycloak的页面,输入之前创建的用户名和密码,验证通过之后在返回的网页中,URL里面会包括了access token,拷贝这个token。

然后我们测试调用camunda的API,例如我们调用create deployment的API,创建一个工作流的部署,http://localhost:8080/camunda/engine-rest/deployment/create,直接调用会返回401 unauthorized的错误。在headers里面加入Authorization: Bearer token之后再调用,这是返回了403的错误,消息是没有对Deployment这个resource进行create的Permission。这是我就纳闷了,按照正常设置,用户已经是在camunda-admin这个组里面了,而且在Camunda启动的时候也看到日志显示这个用户组已经获得了所有Resource的ALL Permission,为什么还会出现问题呢?网上搜索了很久也没找到答案,后来我在application.xml里面增加了两行配置,打印DEBUG的日志:

logging.level.org.camunda.bpm.extension.keycloak: DEBUG
logging.level.org.springframework.web.client.RestTemplate: DEBUG

重新启动Camunda,按照之前的流程再次调用创建deployment的API,这次我从日志中看到了以下信息:

2022-10-02 16:38:41.381 DEBUG 14471 --- [nio-8080-exec-6] o.c.b.e.k.rest.KeycloakRestTemplate      : HTTP GET http://localhost:9090/admin/realms/camunda/users?username=b9b42747-01df-4f8c-9520-847de7fa7893
2022-10-02 16:38:41.382 DEBUG 14471 --- [nio-8080-exec-6] o.c.b.e.k.rest.KeycloakRestTemplate      : Accept=[text/plain, application/json, application/*+json, */*]
2022-10-02 16:38:41.408 DEBUG 14471 --- [nio-8080-exec-6] o.c.b.e.k.rest.KeycloakRestTemplate      : Response 200 OK
2022-10-02 16:38:41.408 DEBUG 14471 --- [nio-8080-exec-6] o.c.b.e.k.rest.KeycloakRestTemplate      : Reading to [java.lang.String] as "application/json"
2022-10-02 16:38:41.412 DEBUG 14471 --- [nio-8080-exec-6] org.camunda.bpm.extension.keycloak       : KEYCLOAK-01050 Keycloak group query results: []

感觉camunda调用keycloak的API来查这个用户的信息,但是查到的结果为空。我进keycloak看了一下,发现Camunda调用keycloak API的usename参数其实对应的是keycloak的用户ID,这样是查不到数据的,如果把这个参数改为对应的keycloak的用户名,则能查到数据。看来定位到问题了。再回到这个Camunda的keycloak插件的描述中,可以看到对于useUsernameAsCamundaUserId这个选项的描述是:

Whether to use the Keycloak username attribute as Camunda's user ID. Default is false. In the default case the plugin will use the internal Keycloak ID as Camunda's user ID.

按字面理解,我原来以为如果设置为true则表示用keycloak的用户名来作为Camunda的用户ID,但是我现在设置了true,Camunda还是用keycloak的用户ID来查询,和我的理解不一样。我把这个选项设置为false,再次测试,发现这次就可以了,没有再报403错误了。

最后总结一下,按照本文的设置,如果需要设置用户访问Camunda的权限,那么需要以下步骤的设置:

  1. 在Keycloak里面配置用户,赋予用户对应的role,把用户加到camunda-admin的组里
  2. 用户调用Keycloak的API来获取token,这里需要用有camunda-rest-api client role的client来获取token

如果需要更进一步细化用户的权限管理,可以参考Camunda文档中关于Authorization的描述,赋予用户更细的控制力度。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

gzroy

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值