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+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流程引擎_gzroy的博客-CSDN博客
在pom.xml文件里面增加以下依赖:
- <dependency>
- <groupId>org.camunda.bpm.extensiongroupId>
- <artifactId>camunda-platform-7-keycloakartifactId>
- <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
需要在pom.xml里面添加以下依赖:
- <dependency>
- <groupId>org.springframework.bootgroupId>
- <artifactId>spring-boot-starter-securityartifactId>
- dependency>
- <dependency>
- <groupId>org.springframework.bootgroupId>
- <artifactId>spring-boot-starter-oauth2-clientartifactId>
- 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;
- }
-
- }
我们使用JWT来保护rest API的调用,如以下流程:
在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
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
audienceValidator = new AudienceValidator(configProps.getRequiredAudience()); - OAuth2TokenValidator
withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri); - OAuth2TokenValidator
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
audienceValidator = new AudienceValidator(configProps.getRequiredAudience()); - OAuth2TokenValidator
withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri); - OAuth2TokenValidator
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
> { - @Override
- public Collection
convert(Jwt jwt) { - final Map
realmAccess = (Map) jwt.getClaims().get("realm_access"); - return ((List
)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
{ -
- /** 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
getUserGroups(String userId){ - List
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。
这时会去到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的权限,那么需要以下步骤的设置:
如果需要更进一步细化用户的权限管理,可以参考Camunda文档中关于Authorization的描述,赋予用户更细的控制力度。